optimize & hala madrid y nada mas

This commit is contained in:
AndresDev
2025-05-11 07:32:35 -04:00
parent 49829e1b1b
commit aceff3eb5a
6 changed files with 914 additions and 831 deletions

View File

@@ -4,34 +4,107 @@ import readline from 'readline';
import path from 'path'; import path from 'path';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import axios from 'axios'; import axios from 'axios';
import { URL } from 'url';
import { authenticate } from './v2/login.mjs'; import { authenticate } from './v2/login.mjs';
import musicModule from './v2/music.js'; import { downloadMusicTrack } from './v2/music.mjs';
const { downloadMusicTrack } = musicModule; import { downloadVideo, fetchAvailableVideoStreams } from './v2/video.mjs';
import videoModule from './v2/video.js';
const { downloadVideo, fetchAvailableVideoStreams } = videoModule;
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout 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) { function askQuestion(query) {
return new Promise(resolve => rl.question(query, resolve)); return new Promise(resolve => rl.question(query, resolve));
} }
function extractIdFromUrl(url, expectedType) { async function promptUserForSelection(promptMessage, options, optionFormatter = (opt) => opt.name || opt) {
if (!url || typeof url !== 'string') { console.log(`\n${promptMessage}`);
return null; options.forEach((option, index) => {
} console.log(` ${index + 1}. ${optionFormatter(option)}`);
const regex = new RegExp(`\/(?:browse\/)?${expectedType}\/(\\d+)`); });
const match = url.match(regex);
if (match && match[1]) { let choiceIndex = -1;
return { type: expectedType, id: match[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 null; 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) { async function fetchHtmlContent(url) {
@@ -42,9 +115,6 @@ async function fetchHtmlContent(url) {
'Accept-Language': 'en-US,en;q=0.9', 'Accept-Language': 'en-US,en;q=0.9',
'Host': urlObj.hostname, 'Host': urlObj.hostname,
'Upgrade-Insecure-Requests': '1', 'Upgrade-Insecure-Requests': '1',
'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none', 'Sec-Fetch-Site': 'none',
@@ -53,53 +123,59 @@ async function fetchHtmlContent(url) {
}; };
try { try {
const response = await axios.get(url, { headers, timeout: 15000 }); const response = await axios.get(url, { headers, timeout: APP_CONFIG.DEFAULT_AXIOS_TIMEOUT });
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error(`[fetchHtmlContent] Axios error fetching ${url}: ${error.message}`); console.error(`Error fetching HTML from ${url}: ${error.message}`);
if (error.response) { if (error.response) {
console.error(`[fetchHtmlContent] Status: ${error.response.status}`); console.error(`Status: ${error.response.status}, Data (first 200 chars): ${String(error.response.data).substring(0, 200)}`);
console.error(`[fetchHtmlContent] Data (first 200): ${String(error.response.data).substring(0,200)}`);
} else if (error.request) { } else if (error.request) {
console.error('[fetchHtmlContent] No response received for URL:', url); console.error('No response received from server.');
} }
throw error; throw error;
} }
} }
function parseOgMeta(htmlContent, property) { function parseOgMetaProperty(htmlContent, property) {
const regex = new RegExp(`<meta[^>]*property="og:${property}"[^>]*content="([^"]+)"`, 'i'); const regex = new RegExp(`<meta[^>]*property="og:${property}"[^>]*content="([^"]+)"`, 'i');
const match = htmlContent.match(regex); const match = htmlContent.match(regex);
return match ? match[1] : null; return match ? match[1] : null;
} }
async function fetchSongMetadataForRenaming(trackUrl) { function normalizeTidalTrackUrlForMetadata(inputUrlStr) {
try { try {
let browseTrackUrl = trackUrl; const url = new URL(inputUrlStr);
const urlObjInput = new URL(trackUrl); let path = url.pathname;
if (urlObjInput.pathname.startsWith('/u/')) { if (path.startsWith('/u/')) path = path.substring(2);
urlObjInput.pathname = urlObjInput.pathname.substring(2);
}
if (urlObjInput.pathname.startsWith('/track/')) {
urlObjInput.pathname = '/browse' + urlObjInput.pathname;
}
const pathSegments = urlObjInput.pathname.split('/').filter(Boolean);
let trackIdFromPath = null;
const trackSegmentIndex = pathSegments.indexOf('track');
if (trackSegmentIndex !== -1 && pathSegments.length > trackSegmentIndex + 1) { const segments = path.split('/').filter(Boolean);
trackIdFromPath = pathSegments[trackSegmentIndex + 1]; let trackId = null;
browseTrackUrl = `${urlObjInput.protocol}//${urlObjInput.host}/browse/track/${trackIdFromPath}`;
} else { if (segments[0] === 'track' && segments[1]) {
console.warn(`[fetchSongMetadataForRenaming] Could not normalize to a /browse/track/ URL from: ${trackUrl}. Using it as is for fetching.`); trackId = segments[1];
browseTrackUrl = trackUrl; } else if (segments[0] === 'browse' && segments[1] === 'track' && segments[2]) {
trackId = segments[2];
} }
console.log(`[fetchSongMetadataForRenaming] Fetching HTML from (normalized): ${browseTrackUrl}`); if (trackId && /^\d+$/.test(trackId)) {
const htmlContent = await fetchHtmlContent(browseTrackUrl); 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 title = null;
let artist = null; let artist = null;
@@ -107,279 +183,245 @@ async function fetchSongMetadataForRenaming(trackUrl) {
if (pageTitleTagMatch && pageTitleTagMatch[1] && pageTitleTagMatch[2]) { if (pageTitleTagMatch && pageTitleTagMatch[1] && pageTitleTagMatch[2]) {
title = pageTitleTagMatch[1].trim(); title = pageTitleTagMatch[1].trim();
artist = pageTitleTagMatch[2].trim(); artist = pageTitleTagMatch[2].trim();
console.log(`[fetchSongMetadataForRenaming] From <title> tag - Title: "${title}", Artist: "${artist}"`); console.log(`Metadata from <title>: Title "${title}", Artist "${artist}"`);
} else { } else {
console.log(`[fetchSongMetadataForRenaming] Could not parse <title> tag. Trying og:title.`); const ogTitle = parseOgMetaProperty(htmlContent, 'title');
const ogTitle = parseOgMeta(htmlContent, 'title');
if (ogTitle) { if (ogTitle) {
const parts = ogTitle.split(' - '); const parts = ogTitle.split(' - ');
if (parts.length >= 2) { if (parts.length >= 2) {
title = parts[0].trim(); title = parts[0].trim();
artist = parts.slice(1).join(' - ').trim(); artist = parts.slice(1).join(' - ').trim();
console.log(`[fetchSongMetadataForRenaming] From og:title (assuming "Title - Artist") - Title: "${title}", Artist: "${artist}"`); console.log(`Metadata from og:title (split): Title "${title}", Artist "${artist}"`);
} else { } else {
title = ogTitle.trim(); title = ogTitle.trim();
console.log(`[fetchSongMetadataForRenaming] From og:title (no separator) - Title: "${title}" (Artist not found in og:title alone)`); console.log(`Metadata from og:title (no split): Title "${title}"`);
} }
} else {
console.log("Could not extract title/artist from <title> or og:title tags.");
} }
} }
console.log(`[fetchSongMetadataForRenaming] Final extracted - Title: "${title}", Artist: "${artist}"`);
return { title, artist }; return { title, artist };
} catch (error) { } catch (error) {
console.error(`[fetchSongMetadataForRenaming] Error fetching metadata from URL ${trackUrl}: ${error.message}`); console.error(`Error fetching metadata from ${urlToFetch}: ${error.message}`);
return { title: null, artist: null }; return { title: null, artist: null };
} }
} }
function sanitizeFilename(name) { function sanitizeFilenameSegment(name) {
if (!name || typeof name !== 'string') return ''; if (!name || typeof name !== 'string') return '';
let sanitized = name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_'); let sanitized = name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
sanitized = sanitized.replace(/\s+/g, ' '); sanitized = sanitized.replace(/\s+/g, ' ').trim();
sanitized = sanitized.trim(); return (sanitized === '' || sanitized.match(/^\.+$/)) ? 'untitled' : sanitized;
if (sanitized === '' || sanitized.match(/^\.+$/)) {
return 'untitled';
}
return sanitized.substring(0, 200);
} }
async function handleSongRenaming(originalSongFilePath, songUrl) { async function renameDownloadedSong(originalFilePath, songUrl) {
console.log("Fetching metadata for potential renaming..."); console.log("Fetching metadata for potential renaming...");
const metadata = await fetchSongMetadataForRenaming(songUrl); const { title, artist } = await fetchTrackMetadataForRenaming(songUrl);
let finalFilePath = originalSongFilePath; if (!title || !artist) {
console.log("Skipping renaming: insufficient metadata (title/artist missing).");
if (metadata.title && metadata.artist) { return originalFilePath;
const confirmRename = await askQuestion("Do you want to rename the file based on Artist - Title? (yes/no): ");
if (['yes', 'y'].includes(confirmRename.toLowerCase().trim())) {
const fileExt = path.extname(originalSongFilePath);
const outputDir = path.dirname(originalSongFilePath);
const newBaseName = `${sanitizeFilename(metadata.artist)} - ${sanitizeFilename(metadata.title)}`;
const newFilePath = path.join(outputDir, `${newBaseName}${fileExt}`);
if (newFilePath !== originalSongFilePath) {
try {
console.log(`Attempting to rename "${path.basename(originalSongFilePath)}" to "${path.basename(newFilePath)}"`);
await fs.rename(originalSongFilePath, newFilePath);
console.log(`✅ File renamed successfully to: ${newFilePath}`);
finalFilePath = newFilePath;
} catch (renameError) {
console.error(`❌ Failed to rename file: ${renameError.message}. Proceeding with original filename.`);
}
} else {
console.log("Generated filename is the same as the original or metadata is insufficient. No rename performed.");
}
} else {
console.log("Skipping file renaming as per user choice.");
}
} else {
console.log("Skipping file renaming due to missing title and/or artist metadata.");
} }
return finalFilePath;
}
const doRename = await promptUserForConfirmation("Rename file using Artist - Title?", true);
if (!doRename) {
console.log("Skipping renaming as per user choice.");
return originalFilePath;
}
const AUDIO_QUALITIES = [ const fileExt = path.extname(originalFilePath);
{ name: "Standard (AAC 96 kbps)", apiCode: "LOW" }, const outputDir = path.dirname(originalFilePath);
{ name: "High (AAC 320 kbps)", apiCode: "HIGH" }, const newBaseName = `${sanitizeFilenameSegment(artist)} - ${sanitizeFilenameSegment(title)}`;
{ name: "HiFi (CD Quality FLAC 16-bit/44.1kHz - Lossless)", apiCode: "LOSSLESS" }, let newFilePath = path.join(outputDir, `${newBaseName}${fileExt}`);
{ name: "Max (HiRes FLAC up to 24-bit/192kHz - Lossless)", apiCode: "HI_RES_LOSSLESS" }
];
async function selectAudioQuality() { if (newFilePath.length > APP_CONFIG.MAX_FILENAME_LENGTH) {
console.log("\nAvailable Audio Qualities:"); const excessLength = newFilePath.length - APP_CONFIG.MAX_FILENAME_LENGTH;
AUDIO_QUALITIES.forEach((quality, index) => { const baseNamePathLength = newBaseName.length + outputDir.length + fileExt.length + 1;
console.log(` ${index + 1}. ${quality.name} (API Code: ${quality.apiCode})`);
});
let choiceIndex = -1; if (newBaseName.length > excessLength) {
while (choiceIndex < 0 || choiceIndex >= AUDIO_QUALITIES.length) { const truncatedBaseName = newBaseName.substring(0, newBaseName.length - excessLength - 3) + "...";
const answer = await askQuestion(`Select quality (1-${AUDIO_QUALITIES.length}): `); newFilePath = path.join(outputDir, `${truncatedBaseName}${fileExt}`);
const parsedAnswer = parseInt(answer, 10);
if (!isNaN(parsedAnswer) && parsedAnswer >= 1 && parsedAnswer <= AUDIO_QUALITIES.length) {
choiceIndex = parsedAnswer - 1;
} else { } else {
console.log("Invalid selection. Please enter a number from the list."); console.warn("Filename is too long even after attempting truncation. Using a generic short name.");
newFilePath = path.join(outputDir, `tidal_download_${Date.now()}${fileExt}`);
} }
} }
return AUDIO_QUALITIES[choiceIndex];
}
async function selectVideoQuality(videoId, accessToken) {
console.log("\nFetching available video qualities..."); if (newFilePath === originalFilePath) {
let streams; console.log("Generated filename is same as original. No rename needed.");
return originalFilePath;
}
try { try {
streams = await fetchAvailableVideoStreams(videoId, accessToken); 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) { } catch (error) {
console.error("Error fetching video qualities:", error.message); console.error("Error fetching video qualities:", error.message);
return null; return null;
} }
}
if (!streams || streams.length === 0) { async function handleSongDownload(session, itemUrl, itemId) {
console.log("No video streams found or an error occurred."); const selectedQuality = await selectAudioDownloadQuality();
return null; if (!selectedQuality) {
console.log("No audio quality selected. Aborting song download.");
return;
} }
console.log(`Selected audio quality: ${selectedQuality.name}`);
console.log("\nAvailable Video Qualities (sorted best first by bandwidth):"); const outputDir = path.join(APP_CONFIG.OUTPUT_BASE_DIR, APP_CONFIG.MUSIC_SUBDIR);
streams.forEach((stream, index) => { await fs.mkdir(outputDir, { recursive: true });
console.log(` ${index + 1}. Resolution: ${stream.resolution}, Bandwidth: ${stream.bandwidth} bps, Codecs: ${stream.codecs}`);
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
}); });
let choiceIndex = -1; if (downloadResult && downloadResult.success && downloadResult.filePath) {
while (choiceIndex < 0 || choiceIndex >= streams.length) { console.log(`\n✅ Song ${itemId} (${selectedQuality.apiCode}) download process finished. Original file: ${downloadResult.filePath}`);
const answer = await askQuestion(`Select quality (1-${streams.length}): `); const finalFilePath = await renameDownloadedSong(downloadResult.filePath, itemUrl);
const parsedAnswer = parseInt(answer, 10); console.log(` Final file location: ${finalFilePath}`);
if (!isNaN(parsedAnswer) && parsedAnswer >= 1 && parsedAnswer <= streams.length) { } else {
choiceIndex = parsedAnswer - 1; const errorMsg = downloadResult ? downloadResult.error : 'Unknown download error';
} else { console.error(`\n❌ Song ${itemId} download failed. ${errorMsg}`);
console.log("Invalid selection. Please enter a number from the list.");
}
} }
return streams[choiceIndex]; }
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() { async function main() {
console.log("╔═════════════════════════════════════════════════╗"); console.log(UI_TEXT.WELCOME_BANNER_TOP);
console.log("║ Welcome to Tidal Downloader! ║"); console.log(UI_TEXT.WELCOME_BANNER_MID);
console.log("╚═════════════════════════════════════════════════╝"); console.log(UI_TEXT.WELCOME_BANNER_BOT);
console.log("\nMake sure you have 'aria2c' installed and in your system's PATH."); console.log(UI_TEXT.ARIA2C_NOTICE);
console.log("Downloads will be saved in a './downloads' directory relative to this script."); console.log(UI_TEXT.DOWNLOAD_DIR_NOTICE);
let session; let session;
try { try {
console.log("\nAttempting to authenticate with Tidal..."); console.log(UI_TEXT.AUTHENTICATING_MSG);
session = await authenticate(); session = await authenticate();
} catch (error) { } catch (error) {
console.error("\nFatal error during the authentication process:", error.message); console.error("\nFatal error during authentication:", error.message);
rl.close(); rl.close();
return; return;
} }
if (!session || !session.isAccessTokenValid()) { if (!session || !session.isAccessTokenCurrentlyValid()) {
console.error("\nAuthentication failed, or no valid session obtained. Cannot proceed."); console.error(UI_TEXT.AUTH_FAILED_MSG);
console.log("Please ensure you complete the device authorization if prompted."); console.log(UI_TEXT.AUTH_RETRY_PROMPT);
rl.close(); rl.close();
return; return;
} }
console.log("\n✅ Successfully authenticated with Tidal!"); console.log(UI_TEXT.AUTH_SUCCESS_MSG);
console.log(` User ID: ${session.userId}, Country: ${session.countryCode}`); console.log(` User ID: ${session.userId}, Country: ${session.countryCode}`);
const outputBaseDir = './downloads'; await fs.mkdir(APP_CONFIG.OUTPUT_BASE_DIR, { recursive: true });
mainLoop:
while (true) { while (true) {
console.log("\n---------------------------------------------"); console.log(UI_TEXT.SEPARATOR_LINE);
console.log("What would you like to do?"); const choice = await promptUserForSelection(UI_TEXT.MAIN_MENU_PROMPT, MAIN_MENU_OPTIONS);
console.log(" 1. Download a Song");
console.log(" 2. Download a Music Video");
console.log(" 3. Exit");
let choice = ''; if (choice.id === 'EXIT') {
while (choice !== '1' && choice !== '2' && choice !== '3') { console.log(UI_TEXT.EXIT_MESSAGE);
choice = await askQuestion("Enter your choice (1-3): "); break;
if (choice !== '1' && choice !== '2' && choice !== '3') {
console.log("Invalid choice. Please enter 1, 2, or 3.");
}
} }
if (choice === '3') { const currentItemType = choice.id === 'DOWNLOAD_SONG' ? ITEM_TYPE.SONG : ITEM_TYPE.VIDEO;
console.log("\nExiting. Goodbye! 👋"); const exampleUrl = currentItemType === ITEM_TYPE.SONG ? 'https://tidal.com/browse/track/TRACK_ID' : 'https://tidal.com/browse/video/VIDEO_ID';
break mainLoop; const itemUrl = await askQuestion(`\nPlease enter the Tidal URL for the ${currentItemType} (e.g., ${exampleUrl}): `);
} const idInfo = extractIdFromTidalUrl(itemUrl, currentItemType);
const isSong = choice === '1';
const downloadType = isSong ? 'song' : 'music video';
const idType = isSong ? 'track' : 'video';
const exampleUrl = isSong ? '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 ${downloadType} (e.g., ${exampleUrl}): `);
const idInfo = extractIdFromUrl(itemUrl, idType);
if (!idInfo) { if (!idInfo) {
console.error(`\n❌ Could not extract a ${idType} ID from the URL provided.`); console.error(`\n❌ Could not extract a ${currentItemType} ID from URL: ${itemUrl}`);
console.error(` Please ensure the URL is correct and matches the format: ${exampleUrl}`); console.error(` Ensure URL format is like: ${exampleUrl}`);
continue; continue;
} }
const itemId = idInfo.id; console.log(`\n🆔 Extracted ${idInfo.type} ID: ${idInfo.id}`);
console.log(`\n🆔 Extracted ${idInfo.type} ID: ${itemId}`);
let outputDir;
try { try {
if (isSong) { if (currentItemType === ITEM_TYPE.SONG) {
const selectedQuality = await selectAudioQuality(); await handleSongDownload(session, itemUrl, idInfo.id);
if (!selectedQuality) { } else {
console.log("No audio quality selected. Aborting download."); await handleVideoDownload(session, itemUrl, idInfo.id);
continue;
}
console.log(`Selected audio quality: ${selectedQuality.name} (API Code: ${selectedQuality.apiCode})`);
outputDir = path.join(outputBaseDir, 'music');
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 handleSongRenaming(downloadResult.filePath, itemUrl);
console.log(` Final file location: ${finalFilePath}`);
} else {
console.error(`\n❌ Song ${itemId} download failed. ${downloadResult ? downloadResult.error : 'Unknown error'}`);
}
} else {
const selectedStream = await selectVideoQuality(itemId, session.accessToken);
if (!selectedStream) {
console.log("No video quality selected or error fetching qualities. Aborting download.");
continue;
}
console.log(`Selected video quality: ${selectedStream.resolution} @ ${selectedStream.bandwidth}bps`);
outputDir = path.join(outputBaseDir, 'videos');
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 process finished.`);
} }
} catch (error) { } catch (error) {
console.error(`\nAn error occurred during the download of ${downloadType} ID ${itemId}.`); console.error(`\nError during download of ${currentItemType} ID ${idInfo.id}: ${error.message}`);
console.error(` Specific error: ${error.message}`);
console.error(error.stack); console.error(error.stack);
} }
let another = ''; const Rerun = await promptUserForConfirmation(UI_TEXT.DOWNLOAD_ANOTHER_PROMPT, true);
while (another !== 'yes' && another !== 'y' && another !== 'no' && another !== 'n') { if (!Rerun) {
another = (await askQuestion("\nDo you want to download another item? (yes/no): ")).toLowerCase().trim(); console.log(UI_TEXT.EXIT_MESSAGE);
} break;
if (another === 'no' || another === 'n') {
console.log("\nExiting. Goodbye! 👋");
break mainLoop;
} }
} }
rl.close(); rl.close();
} }
main().catch(error => { main().catch(error => {
console.error("\n🚨 An unexpected critical error occurred in the startup script:", error.message); console.error("\n🚨 Unexpected critical error in application:", error.message);
console.error(error.stack); console.error(error.stack);
if (rl && typeof rl.close === 'function') rl.close(); if (rl && typeof rl.close === 'function') {
rl.close();
}
process.exit(1); process.exit(1);
}); });

View File

@@ -1,282 +1,298 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
const API_CLIENT = { const API_CLIENT_CONFIG = {
clientId: '7m7Ap0JC9j1cOM3n', clientId: '7m7Ap0JC9j1cOM3n',
clientSecret: 'vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=', clientSecret: 'vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=',
scope: 'r_usr w_usr w_sub' scope: 'r_usr w_usr w_sub'
}; };
const AUTH_URL_BASE = 'https://auth.tidal.com/v1/oauth2'; const TIDAL_AUTH_BASE_URL = 'https://auth.tidal.com/v1/oauth2';
const SESSION_STORAGE_FILE = 'tidal_session.json'; const SESSION_PERSISTENCE_FILE = 'tidal_session.json';
const DEFAULT_HTTP_RETRY_DELAY_SECONDS = 20;
const DEFAULT_FETCH_MAX_RETRIES = 3;
const GENERIC_RETRY_BASE_MILLISECONDS = 2000;
class TidalSession { class TidalAuthSession {
constructor(initialData = {}) { constructor(initialSessionData = {}) {
this.deviceCode = initialData.deviceCode || null; const defaults = {
this.userCode = initialData.userCode || null; deviceCode: null,
this.verificationUrl = initialData.verificationUrl || null; userCode: null,
this.authCheckTimeout = initialData.authCheckTimeout || null; verificationUrl: null,
this.authCheckInterval = initialData.authCheckInterval || null; authCheckTimeoutTimestamp: null,
this.userId = initialData.userId || null; authCheckIntervalMs: null,
this.countryCode = initialData.countryCode || null; userId: null,
this.accessToken = initialData.accessToken || null; countryCode: null,
this.refreshToken = initialData.refreshToken || null; accessToken: null,
this.tokenExpiresAt = initialData.tokenExpiresAt || null; refreshToken: null,
tokenExpiresAtTimestamp: null,
};
Object.assign(this, defaults, initialSessionData);
} }
isAccessTokenValid() { isAccessTokenCurrentlyValid() {
return this.accessToken && this.tokenExpiresAt && Date.now() < this.tokenExpiresAt; return this.accessToken && this.tokenExpiresAtTimestamp && Date.now() < this.tokenExpiresAtTimestamp;
} }
hasRefreshToken() { hasValidRefreshToken() {
return !!this.refreshToken; return !!this.refreshToken;
} }
updateTokens(tokenResponse) { updateAccessTokens(tokenApiResponse) {
this.accessToken = tokenResponse.access_token; this.accessToken = tokenApiResponse.access_token;
if (tokenResponse.refresh_token) { if (tokenApiResponse.refresh_token) {
this.refreshToken = tokenResponse.refresh_token; this.refreshToken = tokenApiResponse.refresh_token;
} }
this.tokenExpiresAt = Date.now() + (tokenResponse.expires_in * 1000); this.tokenExpiresAtTimestamp = Date.now() + (tokenApiResponse.expires_in * 1000);
if (tokenResponse.user) { if (tokenApiResponse.user) {
this.userId = tokenResponse.user.userId; this.userId = tokenApiResponse.user.userId;
this.countryCode = tokenResponse.user.countryCode; this.countryCode = tokenApiResponse.user.countryCode;
} }
} }
clearAuthDetails() { invalidateCurrentTokens() {
this.accessToken = null; this.accessToken = null;
this.refreshToken = null; this.refreshToken = null;
this.tokenExpiresAt = null; this.tokenExpiresAtTimestamp = null;
this.userId = null; this.userId = null;
this.countryCode = null; this.countryCode = null;
} }
clearDeviceAuthDetails() { clearActiveDeviceAuthParameters() {
this.deviceCode = null; this.deviceCode = null;
this.userCode = null; this.userCode = null;
this.verificationUrl = null; this.verificationUrl = null;
this.authCheckTimeout = null; this.authCheckTimeoutTimestamp = null;
this.authCheckInterval = null; this.authCheckIntervalMs = null;
} }
} }
async function saveSession(session) { async function persistSession(sessionInstance) {
const dataToSave = { const dataToPersist = {
userId: session.userId, userId: sessionInstance.userId,
countryCode: session.countryCode, countryCode: sessionInstance.countryCode,
accessToken: session.accessToken, accessToken: sessionInstance.accessToken,
refreshToken: session.refreshToken, refreshToken: sessionInstance.refreshToken,
tokenExpiresAt: session.tokenExpiresAt, tokenExpiresAtTimestamp: sessionInstance.tokenExpiresAtTimestamp,
}; };
try { try {
await fs.writeFile(SESSION_STORAGE_FILE, JSON.stringify(dataToSave, null, 2)); await fs.writeFile(SESSION_PERSISTENCE_FILE, JSON.stringify(dataToPersist, null, 2));
console.log(`Session saved to ${SESSION_STORAGE_FILE}`); console.log(`Session data saved to ${SESSION_PERSISTENCE_FILE}`);
} catch (error) { } catch (error) {
console.error(`Error saving session to ${SESSION_STORAGE_FILE}:`, error.message); console.error(`Failed to save session to ${SESSION_PERSISTENCE_FILE}:`, error.message);
} }
} }
async function loadSession() { async function retrievePersistedSession() {
try { try {
const data = await fs.readFile(SESSION_STORAGE_FILE, 'utf8'); const rawData = await fs.readFile(SESSION_PERSISTENCE_FILE, 'utf8');
const loadedData = JSON.parse(data); const loadedSessionData = JSON.parse(rawData);
console.log(`Session loaded from ${SESSION_STORAGE_FILE}`); console.log(`Session data loaded from ${SESSION_PERSISTENCE_FILE}`);
return new TidalSession(loadedData); return new TidalAuthSession(loadedSessionData);
} catch (error) { } catch (error) {
if (error.code !== 'ENOENT') { if (error.code === 'ENOENT') {
console.warn(`Could not load session from ${SESSION_STORAGE_FILE}: ${error.message}. A new session will be created.`); console.log(`No session file found (${SESSION_PERSISTENCE_FILE}). A new session will be initiated.`);
} else { } else {
console.log(`No session file found at ${SESSION_STORAGE_FILE}. A new session will be created.`); console.warn(`Could not load session from ${SESSION_PERSISTENCE_FILE} (${error.message}). A new session will be initiated.`);
} }
return new TidalSession(); return new TidalAuthSession();
} }
} }
async function fetchWithRetry(url, options, maxRetries = 3) { async function fetchWithRetries(url, fetchOptions, maxRetries = DEFAULT_FETCH_MAX_RETRIES) {
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
const response = await fetch(url, options); const response = await fetch(url, fetchOptions);
if (response.status === 429) { if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || "20", 10); const retryAfterSeconds = parseInt(response.headers.get('Retry-After') || String(DEFAULT_HTTP_RETRY_DELAY_SECONDS), 10);
console.warn(`Rate limit hit (429) for ${url}. Retrying after ${retryAfter} seconds... (Attempt ${attempt + 1}/${maxRetries})`); console.warn(`Rate limit hit for ${url}. Retrying after ${retryAfterSeconds}s. (Attempt ${attempt}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000));
continue; continue;
} }
return response; return response;
} catch (error) { } catch (error) {
console.warn(`Fetch attempt ${attempt + 1}/${maxRetries} failed for ${url}: ${error.message}`); console.warn(`Fetch attempt ${attempt}/${maxRetries} for ${url} failed: ${error.message}`);
if (attempt === maxRetries - 1) throw error; if (attempt === maxRetries) {
await new Promise(resolve => setTimeout(resolve, 2000 * (attempt + 1))); throw new Error(`Failed to fetch ${url} after ${maxRetries} attempts: ${error.message}`);
}
await new Promise(resolve => setTimeout(resolve, GENERIC_RETRY_BASE_MILLISECONDS * attempt));
} }
} }
throw new Error(`Failed to fetch ${url} after ${maxRetries} retries`); throw new Error(`Exhausted all retries for ${url} without a successful fetch or specific non-retryable error.`);
} }
function createBasicAuthHeader(clientId, clientSecret) {
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
}
async function getDeviceCode(session) { async function parseErrorResponse(response) {
console.log("Requesting new device code..."); try {
const body = new URLSearchParams({ return await response.json();
client_id: API_CLIENT.clientId, } catch (e) {
scope: API_CLIENT.scope return { messageFromStatus: response.statusText, status: response.status };
}
}
async function requestDeviceCode(sessionInstance) {
console.log("Requesting new device authorization code...");
const requestBody = new URLSearchParams({
client_id: API_CLIENT_CONFIG.clientId,
scope: API_CLIENT_CONFIG.scope
}); });
const response = await fetchWithRetry(`${AUTH_URL_BASE}/device_authorization`, { const response = await fetchWithRetries(`${TIDAL_AUTH_BASE_URL}/device_authorization`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString() body: requestBody.toString()
}); });
if (!response.ok) { if (!response.ok) {
let errorData; const errorDetails = await parseErrorResponse(response);
try { const errorMessage = errorDetails.userMessage || errorDetails.message || errorDetails.messageFromStatus || 'Unknown server error';
errorData = await response.json(); throw new Error(`Failed to request device code: ${response.status} - ${errorMessage}`);
} catch (e) {
errorData = { message: response.statusText };
}
throw new Error(`Failed to get device code: ${response.status} - ${errorData.userMessage || errorData.message || 'Unknown error'}`);
} }
const data = await response.json(); const responseData = await response.json();
session.deviceCode = data.deviceCode; sessionInstance.deviceCode = responseData.deviceCode;
session.userCode = data.userCode; sessionInstance.userCode = responseData.userCode;
session.verificationUrl = data.verificationUriComplete || data.verificationUri; sessionInstance.verificationUrl = responseData.verificationUriComplete || responseData.verificationUri;
session.authCheckTimeout = Date.now() + (data.expiresIn * 1000); sessionInstance.authCheckTimeoutTimestamp = Date.now() + (responseData.expiresIn * 1000);
session.authCheckInterval = data.interval * 1000; sessionInstance.authCheckIntervalMs = responseData.interval * 1000;
console.log("\n---------------------------------------------------------------------"); console.log("\n--- TIDAL DEVICE AUTHENTICATION REQUIRED ---");
console.log(" TIDAL DEVICE AUTHENTICATION REQUIRED"); console.log(`1. Open a web browser and go to: https://${sessionInstance.verificationUrl}`);
console.log("---------------------------------------------------------------------"); console.log(`2. If prompted, enter this code: ${sessionInstance.userCode}`);
console.log(`1. Open your web browser and go to: https://${session.verificationUrl}`); console.log("--- WAITING FOR AUTHORIZATION ---");
console.log(`2. Enter the following code: ${session.userCode} if asked`);
console.log("---------------------------------------------------------------------");
console.log("\nWaiting for authorization (this may take a moment)...");
} }
async function pollForToken(session) { async function pollForDeviceAccessToken(sessionInstance) {
const basicAuth = `Basic ${Buffer.from(`${API_CLIENT.clientId}:${API_CLIENT.clientSecret}`).toString('base64')}`; const basicAuthToken = createBasicAuthHeader(API_CLIENT_CONFIG.clientId, API_CLIENT_CONFIG.clientSecret);
const body = new URLSearchParams({ const requestBody = new URLSearchParams({
client_id: API_CLIENT.clientId, client_id: API_CLIENT_CONFIG.clientId,
device_code: session.deviceCode, device_code: sessionInstance.deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code', grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
scope: API_CLIENT.scope scope: API_CLIENT_CONFIG.scope
}); });
while (Date.now() < session.authCheckTimeout) { process.stdout.write("Polling for authorization");
await new Promise(resolve => setTimeout(resolve, session.authCheckInterval)); while (Date.now() < sessionInstance.authCheckTimeoutTimestamp) {
await new Promise(resolve => setTimeout(resolve, sessionInstance.authCheckIntervalMs));
process.stdout.write("."); process.stdout.write(".");
const response = await fetchWithRetry(`${AUTH_URL_BASE}/token`, { const response = await fetchWithRetries(`${TIDAL_AUTH_BASE_URL}/token`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': basicAuth 'Authorization': basicAuthToken
}, },
body: body.toString() body: requestBody.toString()
}); });
if (response.ok) { if (response.ok) {
const tokenData = await response.json(); const tokenData = await response.json();
session.updateTokens(tokenData); sessionInstance.updateAccessTokens(tokenData);
console.log("\nAuthorization successful!"); process.stdout.write("\n");
await saveSession(session); console.log("Authorization successful!");
session.clearDeviceAuthDetails(); await persistSession(sessionInstance);
sessionInstance.clearActiveDeviceAuthParameters();
return true; return true;
} else { }
let errorData;
try {
errorData = await response.json();
} catch (e) {
errorData = { status: response.status, sub_status: 0, userMessage: response.statusText };
}
if (errorData.status === 400 && errorData.sub_status === 1002) { const errorData = await parseErrorResponse(response);
// Authorization pending, continue polling if (errorData.status === 400 && errorData.sub_status === 1002) {
} else { continue;
console.error(`\nError polling for token: ${errorData.status} - ${errorData.sub_status || ''} - ${errorData.userMessage || errorData.error_description || 'Unknown error'}`); } else {
session.clearDeviceAuthDetails(); process.stdout.write("\n");
return false; const userMessage = errorData.userMessage || errorData.error_description || errorData.messageFromStatus || 'Unknown polling error';
} console.error(`Error polling for token: ${errorData.status} ${errorData.sub_status || ''} - ${userMessage}`);
sessionInstance.clearActiveDeviceAuthParameters();
return false;
} }
} }
console.log("\nDevice-code authorization timed out."); process.stdout.write("\n");
session.clearDeviceAuthDetails(); console.log("Device-code authorization timed out.");
sessionInstance.clearActiveDeviceAuthParameters();
return false; return false;
} }
async function refreshAccessToken(session) { async function attemptTokenRefresh(sessionInstance) {
if (!session.hasRefreshToken()) { if (!sessionInstance.hasValidRefreshToken()) {
console.log("No refresh token available to refresh session."); console.log("No refresh token available. Cannot refresh session.");
return false; return false;
} }
console.log("Attempting to refresh access token..."); console.log("Attempting to refresh access token...");
const basicAuth = `Basic ${Buffer.from(`${API_CLIENT.clientId}:${API_CLIENT.clientSecret}`).toString('base64')}`; const basicAuthToken = createBasicAuthHeader(API_CLIENT_CONFIG.clientId, API_CLIENT_CONFIG.clientSecret);
const body = new URLSearchParams({ const requestBody = new URLSearchParams({
client_id: API_CLIENT.clientId, client_id: API_CLIENT_CONFIG.clientId,
refresh_token: session.refreshToken, refresh_token: sessionInstance.refreshToken,
grant_type: 'refresh_token', grant_type: 'refresh_token',
scope: API_CLIENT.scope scope: API_CLIENT_CONFIG.scope
}); });
const response = await fetchWithRetry(`${AUTH_URL_BASE}/token`, { const response = await fetchWithRetries(`${TIDAL_AUTH_BASE_URL}/token`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': basicAuth 'Authorization': basicAuthToken
}, },
body: body.toString() body: requestBody.toString()
}); });
if (response.ok) { if (response.ok) {
const tokenData = await response.json(); const tokenData = await response.json();
session.updateTokens(tokenData); sessionInstance.updateAccessTokens(tokenData);
console.log("Access token refreshed successfully."); console.log("Access token refreshed successfully.");
await saveSession(session); await persistSession(sessionInstance);
return true; return true;
} else {
let errorData;
try {
errorData = await response.json();
} catch(e) {
errorData = {};
}
console.error(`Failed to refresh access token: ${response.status} - ${errorData.userMessage || errorData.error_description || 'Unknown error'}`);
session.clearAuthDetails();
await saveSession(session);
return false;
} }
const errorData = await parseErrorResponse(response);
const userMessage = errorData.userMessage || errorData.error_description || errorData.messageFromStatus || 'Unknown token refresh error';
console.error(`Failed to refresh access token: ${response.status} - ${userMessage}`);
sessionInstance.invalidateCurrentTokens();
await persistSession(sessionInstance);
return false;
} }
async function authenticate() { async function establishAuthenticatedSession() {
let session = await loadSession(); let currentSession = await retrievePersistedSession();
if (session.isAccessTokenValid()) { if (currentSession.isAccessTokenCurrentlyValid()) {
console.log("Found valid access token in session. Authentication successful (using existing session)."); console.log("Valid access token found. Authentication successful (using existing session).");
return session; return currentSession;
} }
if (session.hasRefreshToken()) { if (currentSession.hasValidRefreshToken()) {
console.log("Access token expired or invalid. Attempting to use refresh token."); console.log("Access token expired or invalid. Attempting to use refresh token.");
if (await refreshAccessToken(session)) { if (await attemptTokenRefresh(currentSession)) {
console.log("Authentication successful (after refreshing token)."); console.log("Authentication successful (token refreshed).");
return session; return currentSession;
} }
} }
console.log("No valid session found or refresh failed. Starting new device authentication flow."); console.log("No valid session or refresh failed. Initiating new device authentication.");
session.clearAuthDetails(); currentSession.invalidateCurrentTokens();
try { try {
await getDeviceCode(session); await requestDeviceCode(currentSession);
if (await pollForToken(session)) { if (await pollForDeviceAccessToken(currentSession)) {
console.log("Authentication successful (new device authorization)."); console.log("Authentication successful (new device authorization).");
return session; return currentSession;
} else { } else {
console.error("Device authentication process failed or timed out."); console.error("Device authentication process failed or timed out.");
return null; return null;
} }
} catch (error) { } catch (error) {
console.error(`Device authentication flow error: ${error.message}`); console.error(`Device authentication flow encountered an error: ${error.message}`);
return null; return null;
} }
} }
export { authenticate, TidalSession, saveSession, loadSession, refreshAccessToken, getDeviceCode, pollForToken, API_CLIENT }; export {
establishAuthenticatedSession as authenticate,
TidalAuthSession as TidalSession,
persistSession as saveSession,
retrievePersistedSession as loadSession,
attemptTokenRefresh as refreshAccessToken,
requestDeviceCode as getDeviceCode,
pollForDeviceAccessToken as pollForToken,
API_CLIENT_CONFIG as API_CLIENT
};

View File

@@ -1,159 +0,0 @@
'use strict';
const axios = require('axios');
const { parseStringPromise } = require('xml2js');
const { exec } = require('child_process');
const { promises: fs, createWriteStream } = require('fs');
const path = require('path');
const util = require('util');
const execPromise = util.promisify(exec);
async function downloadMusicTrack(options) {
const {
trackId,
audioQuality,
accessToken,
outputDir = '.',
tempDirPrefix = 'temp_music',
} = options;
if (!trackId || !accessToken || !audioQuality) {
throw new Error('trackId, accessToken, and audioQuality are required options.');
}
const url = `https://listen.tidal.com/v1/tracks/${trackId}/playbackinfo?audioquality=${audioQuality}&playbackmode=STREAM&assetpresentation=FULL`;
const headers = {
'authorization': `Bearer ${accessToken}`,
};
const outputFilename = path.join(outputDir, `${trackId}_${audioQuality}.flac`);
const tempDir = path.join(outputDir, `${tempDirPrefix}_${trackId}_${audioQuality}`);
let aria2cInputFile = '';
try {
console.log(`Requesting playback info for track ${trackId} (Quality: ${audioQuality})...`);
const response = await axios.get(url, { headers });
if (!response.data || !response.data.manifest) {
let errorMsg = 'Manifest not found in API response.';
if (response.data && response.data.userMessage) {
errorMsg += ` Server message: ${response.data.userMessage}`;
} else if (response.data && response.data.status && response.data.title) {
errorMsg += ` Server error ${response.data.status}: ${response.data.title}`;
}
if (response.status === 404 && audioQuality) {
errorMsg += ` The audio quality '${audioQuality}' might not be available for this track.`;
}
throw new Error(errorMsg);
}
const manifestBase64 = response.data.manifest;
const trackIdFromResponse = response.data.trackId;
console.log(`Received playback info for track ${trackIdFromResponse}.`);
const manifestXml = Buffer.from(manifestBase64, 'base64').toString('utf8');
console.log('Parsing XML manifest...');
const parsedXml = await parseStringPromise(manifestXml);
const representation = parsedXml.MPD.Period[0].AdaptationSet[0].Representation[0];
const segmentTemplate = representation.SegmentTemplate[0];
const segmentTimeline = segmentTemplate.SegmentTimeline[0].S;
const initializationUrl = segmentTemplate.$.initialization;
const mediaUrlTemplate = segmentTemplate.$.media;
const startNumber = parseInt(segmentTemplate.$.startNumber, 10);
const segmentUrls = [initializationUrl];
const segmentFilenames = [];
const initFilename = path.basename(new URL(initializationUrl).pathname);
segmentFilenames.push(initFilename);
let currentSegment = startNumber;
segmentTimeline.forEach(segment => {
const duration = parseInt(segment.$.d, 10);
const repeat = segment.$.r ? parseInt(segment.$.r, 10) : 0;
for (let i = 0; i <= repeat; i++) {
const url = mediaUrlTemplate.replace('$Number$', currentSegment.toString());
segmentUrls.push(url);
const filename = path.basename(new URL(url).pathname).replace(/\?.*/, '');
segmentFilenames.push(filename);
currentSegment++;
}
});
console.log(`Found ${segmentUrls.length} segments (1 init + ${segmentUrls.length - 1} media).`);
console.log(`Ensuring output directory exists: ${outputDir}`);
await fs.mkdir(outputDir, { recursive: true });
console.log(`Creating temporary directory: ${tempDir}`);
await fs.mkdir(tempDir, { recursive: true });
aria2cInputFile = path.join(tempDir, 'urls.txt');
await fs.writeFile(aria2cInputFile, segmentUrls.join('\n'));
console.log('Generated URL list for aria2c.');
const aria2cCommand = `aria2c --console-log-level=warn -c -x 16 -s 16 -k 1M -j 16 -d "${tempDir}" -i "${aria2cInputFile}"`;
console.log('Starting download with aria2c...');
console.log(`Executing: ${aria2cCommand}`);
await execPromise(aria2cCommand);
console.log('aria2c download completed.');
console.log(`Concatenating segments into ${outputFilename}...`);
const outputStream = createWriteStream(outputFilename);
const orderedSegmentPaths = segmentFilenames.map(fname => path.join(tempDir, fname));
for (const segmentPath of orderedSegmentPaths) {
try {
await fs.access(segmentPath);
const segmentData = await fs.readFile(segmentPath);
outputStream.write(segmentData);
} catch (err) {
console.error(`Error accessing or appending segment ${segmentPath}: ${err.message}. Skipping.`);
}
}
outputStream.end();
await new Promise((resolve, reject) => {
outputStream.on('finish', resolve);
outputStream.on('error', reject);
});
console.log(`Successfully created ${outputFilename}.`);
return { success: true, filePath: path.resolve(outputFilename) };
} catch (error) {
console.error(`An error occurred during download for track ${trackId} (Quality: ${audioQuality}):`);
if (error.response) {
console.error(` Status: ${error.response.status}`);
console.error(` Data: ${JSON.stringify(error.response.data)}`);
} else if (error.request) {
console.error(' No response received:', error.request);
} else {
console.error(' Error:', error.message);
if (error.stderr) {
console.error(' Stderr:', error.stderr);
}
if (error.stdout) {
console.error(' Stdout:', error.stdout);
}
}
throw error;
} finally {
try {
if (aria2cInputFile && await fs.stat(tempDir).catch(() => false)) {
console.log(`Cleaning up temporary directory: ${tempDir}`);
await fs.rm(tempDir, { recursive: true, force: true });
console.log('Cleanup complete.');
}
} catch (cleanupError) {
console.error(`Failed to cleanup temporary directory ${tempDir}: ${cleanupError.message}`);
}
}
}
module.exports = { downloadMusicTrack };

184
v2/music.mjs Normal file
View File

@@ -0,0 +1,184 @@
'use strict';
import axios from 'axios';
import { parseStringPromise } from 'xml2js';
import { exec } from 'child_process';
import { promises as fs, createWriteStream } from 'fs';
import path from 'path';
import util from 'util';
const execAsync = util.promisify(exec);
const TIDAL_API_BASE_URL = 'https://listen.tidal.com/v1';
const ARIA2C_DEFAULT_OPTIONS = '-c -x 16 -s 16 -k 1M -j 16 --console-log-level=warn --allow-overwrite=true --auto-file-renaming=false';
function buildPlaybackInfoUrl(trackId, audioQuality) {
return `${TIDAL_API_BASE_URL}/tracks/${trackId}/playbackinfo?audioquality=${audioQuality}&playbackmode=STREAM&assetpresentation=FULL`;
}
function buildApiErrorMessage(prefix, errorResponse, audioQualityForContext = null) {
let message = prefix;
const responseData = errorResponse?.data;
if (responseData?.userMessage) {
message += ` Server message: ${responseData.userMessage}`;
} else if (responseData?.title) {
message += ` Title: ${responseData.title}`;
} else if (typeof responseData === 'string' && responseData.length < 250) {
message += ` Body: ${responseData}`;
}
if (errorResponse?.status === 404 && audioQualityForContext) {
message += ` The audio quality '${audioQualityForContext}' might not be available for this track, or the track ID is invalid.`;
}
return message;
}
async function parseManifestAndExtractSegments(manifestXml) {
const parsedXml = await parseStringPromise(manifestXml);
const representation = parsedXml?.MPD?.Period?.[0]?.AdaptationSet?.[0]?.Representation?.[0];
if (!representation) {
throw new Error('Could not find Representation element in XML manifest.');
}
const segmentTemplate = representation.SegmentTemplate?.[0];
if (!segmentTemplate) {
throw new Error('Could not find SegmentTemplate element in XML manifest.');
}
const initializationUrlPath = segmentTemplate.$?.initialization;
const mediaUrlTemplate = segmentTemplate.$?.media;
const startNumberStr = segmentTemplate.$?.startNumber;
if (!initializationUrlPath || !mediaUrlTemplate || !startNumberStr) {
throw new Error('Manifest SegmentTemplate is missing critical attributes (initialization, media, or startNumber).');
}
const segmentTimelineSlices = segmentTemplate.SegmentTimeline?.[0]?.S;
if (!segmentTimelineSlices || !Array.isArray(segmentTimelineSlices) || segmentTimelineSlices.length === 0) {
throw new Error('Manifest SegmentTimeline S array is missing or empty.');
}
const segmentUrls = [initializationUrlPath];
const segmentBasenames = [path.basename(new URL(initializationUrlPath, 'http://dummybase').pathname)]; // Base URL for relative paths
let currentSegmentNumber = parseInt(startNumberStr, 10);
segmentTimelineSlices.forEach(segment => {
const repeatCount = segment.$.r ? parseInt(segment.$.r, 10) : 0;
for (let i = 0; i <= repeatCount; i++) {
const mediaUrl = mediaUrlTemplate.replace('$Number$', currentSegmentNumber.toString());
segmentUrls.push(mediaUrl);
segmentBasenames.push(path.basename(new URL(mediaUrl, 'http://dummybase').pathname).replace(/\?.*/, ''));
currentSegmentNumber++;
}
});
return { segmentUrls, segmentBasenames };
}
async function downloadMusicTrack(options) {
const {
trackId,
audioQuality,
accessToken,
outputDir = '.',
tempDirPrefix = 'temp_tidal_music',
} = options;
if (!trackId || !audioQuality || !accessToken) {
throw new Error('trackId, audioQuality, and accessToken are mandatory options.');
}
const playbackInfoUrl = buildPlaybackInfoUrl(trackId, audioQuality);
const apiHeaders = { 'Authorization': `Bearer ${accessToken}` };
const outputFileName = `${trackId}_${audioQuality}.flac`;
const outputFilePath = path.join(outputDir, outputFileName);
let tempDirPath = '';
try {
console.log(`Requesting playback info for track ${trackId} (Quality: ${audioQuality})...`);
const response = await axios.get(playbackInfoUrl, { headers: apiHeaders });
const playbackData = response.data;
if (!playbackData?.manifest) {
const detail = playbackData?.userMessage || playbackData?.title || 'Manifest not found in API response.';
throw new Error(`Playback info response missing manifest: ${detail}`);
}
console.log(`Received playback info for track ${playbackData.trackId}.`);
const manifestXml = Buffer.from(playbackData.manifest, 'base64').toString('utf8');
console.log('Parsing XML manifest...');
const { segmentUrls, segmentBasenames } = await parseManifestAndExtractSegments(manifestXml);
console.log(`Found ${segmentUrls.length} segments to download.`);
tempDirPath = path.join(outputDir, `${tempDirPrefix}_${trackId}_${audioQuality}_${Date.now()}`);
await fs.mkdir(tempDirPath, { recursive: true });
console.log(`Temporary directory created: ${tempDirPath}`);
const aria2cInputFilePath = path.join(tempDirPath, 'segment_urls.txt');
await fs.writeFile(aria2cInputFilePath, segmentUrls.join('\n'));
console.log('Generated URL list for aria2c.');
const aria2cCommand = `aria2c ${ARIA2C_DEFAULT_OPTIONS} -d "${tempDirPath}" -i "${aria2cInputFilePath}"`;
console.log('Starting download with aria2c...');
console.log(`Executing: ${aria2cCommand}`);
await execAsync(aria2cCommand);
console.log('aria2c download process completed.');
console.log(`Concatenating ${segmentBasenames.length} segments into ${outputFilePath}...`);
const outputStream = createWriteStream(outputFilePath);
try {
for (const segmentName of segmentBasenames) {
const segmentPath = path.join(tempDirPath, segmentName);
try {
const segmentData = await fs.readFile(segmentPath);
outputStream.write(segmentData);
} catch (readError) {
console.warn(`Segment ${segmentPath} unreadable or missing: ${readError.message}. Skipping.`);
}
}
} finally {
outputStream.end();
}
await new Promise((resolve, reject) => {
outputStream.on('finish', resolve);
outputStream.on('error', (err) => reject(new Error(`Error writing to output file ${outputFilePath}: ${err.message}`)));
});
console.log(`Successfully created output file: ${outputFilePath}`);
return { success: true, filePath: path.resolve(outputFilePath) };
} catch (error) {
let errorMessage = `Error during download for track ${trackId} (Quality: ${audioQuality}): ${error.message}`;
if (axios.isAxiosError(error)) {
errorMessage = buildApiErrorMessage(`API request failed with status ${error.response?.status || 'unknown'}.`, error.response, audioQuality);
if (error.response?.data && typeof error.response.data === 'object') {
console.error(`Full API error response: ${JSON.stringify(error.response.data, null, 2)}`);
}
} else if (error.stderr || error.stdout) {
errorMessage += `\n aria2c Stderr: ${error.stderr}\n aria2c Stdout: ${error.stdout}`;
}
console.error(errorMessage);
throw error;
} finally {
if (tempDirPath) {
try {
await fs.stat(tempDirPath);
console.log(`Cleaning up temporary directory: ${tempDirPath}`);
await fs.rm(tempDirPath, { recursive: true, force: true });
console.log('Temporary directory cleanup complete.');
} catch (cleanupError) {
if (cleanupError.code === 'ENOENT') {
} else {
console.error(`Failed to cleanup temporary directory ${tempDirPath}: ${cleanupError.message}`);
}
}
}
}
}
export { downloadMusicTrack };

View File

@@ -1,279 +0,0 @@
'use strict';
const axios = require('axios');
const { exec } = require('child_process');
const { promises: fs, createWriteStream } = require('fs');
const path = require('path');
const util = require('util');
const readline = require('readline');
const execPromise = util.promisify(exec);
const DEFAULT_PLAYBACKINFO_VIDEO_QUALITY = 'HIGH';
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36';
function parseM3U8Master(m3u8Content) {
const lines = m3u8Content.split('\n');
const streams = [];
let currentStreamInfo = null;
for (const line of lines) {
if (line.startsWith('#EXT-X-STREAM-INF:')) {
currentStreamInfo = line;
} else if (currentStreamInfo && (line.startsWith('http://') || line.startsWith('https://'))) {
const resolutionMatch = currentStreamInfo.match(/RESOLUTION=(\d+x\d+)/);
const bandwidthMatch = currentStreamInfo.match(/BANDWIDTH=(\d+)/);
const codecsMatch = currentStreamInfo.match(/CODECS="([^"]+)"/);
const resolution = resolutionMatch ? resolutionMatch[1] : 'Unknown';
const bandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;
const codecs = codecsMatch ? codecsMatch[1] : 'Unknown';
streams.push({
resolution: resolution,
bandwidth: bandwidth,
codecs: codecs,
url: line.trim()
});
currentStreamInfo = null;
}
}
streams.sort((a, b) => b.bandwidth - a.bandwidth);
return streams;
}
function parseM3U8Media(m3u8Content) {
const lines = m3u8Content.split('\n');
const segmentUrls = [];
const segmentFilenames = [];
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.length > 0 && !trimmedLine.startsWith('#')) {
segmentUrls.push(trimmedLine);
try {
const urlObject = new URL(trimmedLine);
const baseName = path.basename(urlObject.pathname);
segmentFilenames.push(baseName.includes('.') ? baseName : `segment_${segmentUrls.length}.ts`);
} catch (e) {
console.warn(`Could not parse URL to get filename: ${trimmedLine}, using generic name.`);
segmentFilenames.push(`segment_${segmentUrls.length}.ts`);
}
}
}
return { urls: segmentUrls, filenames: segmentFilenames };
}
async function fetchAvailableVideoStreams(videoId, accessToken, userAgent = DEFAULT_USER_AGENT) {
if (!videoId || !accessToken) {
throw new Error('videoId and accessToken are required to fetch video streams.');
}
const apiUrl = `https://listen.tidal.com/v1/videos/${videoId}/playbackinfo?videoquality=${DEFAULT_PLAYBACKINFO_VIDEO_QUALITY}&playbackmode=STREAM&assetpresentation=FULL`;
const headers = {
'authorization': `Bearer ${accessToken}`,
'User-Agent': userAgent
};
console.log(`Requesting playback info for video ${videoId} to get stream list...`);
const response = await axios.get(apiUrl, { headers });
if (!response.data || !response.data.manifest) {
let errorMsg = 'Manifest not found in API response when fetching video streams.';
if (response.data && response.data.userMessage) {
errorMsg += ` Server message: ${response.data.userMessage}`;
}
throw new Error(errorMsg);
}
const manifestBase64 = response.data.manifest;
const manifestJsonString = Buffer.from(manifestBase64, 'base64').toString('utf8');
const manifestJson = JSON.parse(manifestJsonString);
if (!manifestJson.urls || manifestJson.urls.length === 0) {
throw new Error('Master M3U8 URL not found in manifest.');
}
const masterM3U8Url = manifestJson.urls[0];
console.log(`Found master playlist: ${masterM3U8Url}`);
console.log('Fetching master playlist...');
const masterPlaylistResponse = await axios.get(masterM3U8Url, { headers });
const masterPlaylistContent = masterPlaylistResponse.data;
const availableStreams = parseM3U8Master(masterPlaylistContent);
if (availableStreams.length === 0) {
throw new Error('No video streams found in master playlist.');
}
return availableStreams;
}
async function scrapeTitleFromUrl(url, userAgent = DEFAULT_USER_AGENT) {
try {
const response = await axios.get(url, { headers: { 'User-Agent': userAgent } });
const htmlContent = response.data;
const titleMatch = htmlContent.match(/<title>(.*?)<\/title>/i);
if (titleMatch && titleMatch[1]) {
let title = titleMatch[1];
title = title.replace(/\s*on TIDAL$/i, '').trim();
title = title.replace(/[<>:"/\\|?*]+/g, '_');
title = title.replace(/\.$/, '_');
return title;
}
return null;
} catch (error) {
console.warn(`Failed to scrape title from ${url}: ${error.message}`);
return null;
}
}
function askQuestion(query) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(resolve => rl.question(query, ans => {
rl.close();
resolve(ans);
}));
}
async function downloadVideo(options) {
const {
videoId,
accessToken,
selectedStreamUrl,
tidalUrl,
userAgent = DEFAULT_USER_AGENT,
outputDir = '.',
tempDirPrefix = 'temp_video'
} = options;
if (!videoId || !accessToken || !selectedStreamUrl) {
throw new Error('videoId, accessToken, and selectedStreamUrl are required options.');
}
const headers = {
'authorization': `Bearer ${accessToken}`,
'User-Agent': userAgent
};
let outputFilename;
let tempDirNameBase;
if (tidalUrl) {
const scrapedTitle = await scrapeTitleFromUrl(tidalUrl, userAgent);
if (scrapedTitle) {
const proposedFullFilename = `${scrapedTitle}.ts`;
const useScrapedNameAnswer = await askQuestion(`Use "${proposedFullFilename}" as filename? (y/n): `);
if (useScrapedNameAnswer.toLowerCase() === 'y') {
outputFilename = proposedFullFilename;
tempDirNameBase = scrapedTitle;
}
}
}
if (!outputFilename) {
let qualityIdentifier = 'selected';
try {
const urlParts = selectedStreamUrl.split('/');
const qualityPart = urlParts.find(part => part.match(/^\d+p$/) || part.match(/^\d+k$/));
if (qualityPart) qualityIdentifier = qualityPart;
else {
const resMatch = selectedStreamUrl.match(/(\d+x\d+)/);
if (resMatch) qualityIdentifier = resMatch[1];
}
} catch (e) { /* ignore */ }
outputFilename = `${videoId}_${qualityIdentifier}.ts`;
tempDirNameBase = `${videoId}_${qualityIdentifier}`;
}
const outputFilePath = path.resolve(outputDir, outputFilename);
const safeTempDirNameBase = tempDirNameBase.replace(/[<>:"/\\|?*]+/g, '_').replace(/\.$/, '_');
const tempDirPath = path.resolve(outputDir, `${tempDirPrefix}_${safeTempDirNameBase}`);
let aria2cInputFilePath = '';
try {
console.log(`Using selected stream URL: ${selectedStreamUrl}`);
console.log('Fetching media playlist for selected quality...');
const mediaPlaylistResponse = await axios.get(selectedStreamUrl, { headers });
const mediaPlaylistContent = mediaPlaylistResponse.data;
const { urls: segmentUrls, filenames: segmentFilenames } = parseM3U8Media(mediaPlaylistContent);
if (segmentUrls.length === 0) {
throw new Error('No segments found in the selected media playlist.');
}
console.log(`Found ${segmentUrls.length} video segments.`);
console.log(`Ensuring output directory exists: ${outputDir}`);
await fs.mkdir(outputDir, { recursive: true });
console.log(`Creating temporary directory: ${tempDirPath}`);
await fs.mkdir(tempDirPath, { recursive: true });
aria2cInputFilePath = path.join(tempDirPath, 'urls.txt');
const aria2cUrlsWithOptions = segmentUrls.map(url => {
return `${url}\n header=User-Agent: ${userAgent}`;
});
await fs.writeFile(aria2cInputFilePath, aria2cUrlsWithOptions.join('\n'));
console.log('Generated URL list for aria2c with headers.');
const aria2cCommand = `aria2c --console-log-level=warn -c -x 16 -s 16 -k 1M -j 16 -d "${tempDirPath}" -i "${aria2cInputFilePath}"`;
console.log('Starting download with aria2c...');
console.log(`Executing: ${aria2cCommand}`);
await execPromise(aria2cCommand);
console.log('aria2c download completed.');
console.log(`Concatenating segments into ${outputFilePath}...`);
const outputStream = createWriteStream(outputFilePath);
const orderedSegmentPaths = segmentFilenames.map(fname => path.join(tempDirPath, fname));
for (const segmentPath of orderedSegmentPaths) {
try {
await fs.access(segmentPath);
const segmentData = await fs.readFile(segmentPath);
outputStream.write(segmentData);
} catch (err) {
console.error(`Error accessing or appending segment ${segmentPath}: ${err.message}. Skipping.`);
}
}
outputStream.end();
await new Promise((resolve, reject) => {
outputStream.on('finish', resolve);
outputStream.on('error', reject);
});
console.log(`Successfully created ${outputFilename}.`);
} catch (error) {
console.error(`An error occurred during download for video ${videoId}:`);
if (error.response) {
console.error(` Status: ${error.response.status}`);
console.error(` Data: ${JSON.stringify(error.response.data)}`);
} else if (error.request) {
console.error(' No response received:', error.request);
} else {
console.error(' Error:', error.message);
if (error.stderr) {
console.error(' Stderr:', error.stderr);
}
if (error.stdout) {
console.error(' Stdout:', error.stdout);
}
}
throw error;
} finally {
try {
if (await fs.stat(tempDirPath).catch(() => false)) {
console.log(`Cleaning up temporary directory: ${tempDirPath}`);
await fs.rm(tempDirPath, { recursive: true, force: true });
console.log('Cleanup complete.');
}
} catch (cleanupError) {
console.error(`Failed to cleanup temporary directory ${tempDirPath}: ${cleanupError.message}`);
}
}
}
module.exports = { downloadVideo, fetchAvailableVideoStreams };

279
v2/video.mjs Normal file
View File

@@ -0,0 +1,279 @@
import axios from 'axios';
import { exec } from 'child_process';
import { promises as fs, createWriteStream } from 'fs';
import path from 'path';
import util from 'util';
import readline from 'readline';
const execAsync = util.promisify(exec);
const TIDAL_API_BASE_URL = 'https://listen.tidal.com/v1';
const DEFAULT_PLAYBACKINFO_VIDEO_QUALITY = 'HIGH';
const DEFAULT_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';
const ARIA2C_VIDEO_OPTIONS = '--console-log-level=warn -c -x 16 -s 16 -k 1M -j 16 --allow-overwrite=true --auto-file-renaming=false';
const TEMP_DIR_VIDEO_PREFIX = 'temp_tidal_video';
function sanitizeForFilename(name, replacement = '_') {
if (!name || typeof name !== 'string') return 'untitled';
let sanitized = name.replace(/[<>:"/\\|?*\x00-\x1F]/g, replacement);
sanitized = sanitized.replace(/\s+/g, ' ').trim();
sanitized = sanitized.replace(/\.$/, replacement); // Replace trailing dots
return sanitized.substring(0, 240) || 'untitled'; // Limit length
}
function parseM3U8MasterPlaylist(m3u8Content) {
const lines = m3u8Content.split('\n');
const streams = [];
let currentStreamInfoLine = null;
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('#EXT-X-STREAM-INF:')) {
currentStreamInfoLine = trimmedLine;
} else if (currentStreamInfoLine && (trimmedLine.startsWith('http://') || trimmedLine.startsWith('https://'))) {
const resolutionMatch = currentStreamInfoLine.match(/RESOLUTION=(\d+x\d+)/);
const bandwidthMatch = currentStreamInfoLine.match(/BANDWIDTH=(\d+)/);
const codecsMatch = currentStreamInfoLine.match(/CODECS="([^"]+)"/);
streams.push({
resolution: resolutionMatch ? resolutionMatch[1] : 'Unknown',
bandwidth: bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0,
codecs: codecsMatch ? codecsMatch[1] : 'Unknown',
url: trimmedLine
});
currentStreamInfoLine = null;
}
}
streams.sort((a, b) => b.bandwidth - a.bandwidth);
return streams;
}
function parseM3U8MediaPlaylist(m3u8Content) {
const lines = m3u8Content.split('\n');
const segmentUrls = [];
const segmentFilenames = [];
let segmentCounter = 0;
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.length > 0 && !trimmedLine.startsWith('#')) {
segmentUrls.push(trimmedLine);
segmentCounter++;
try {
const urlObject = new URL(trimmedLine);
const baseName = path.basename(urlObject.pathname);
segmentFilenames.push(baseName.includes('.') ? baseName : `segment_${segmentCounter}.ts`);
} catch (e) {
segmentFilenames.push(`segment_${segmentCounter}.ts`);
}
}
}
return { urls: segmentUrls, filenames: segmentFilenames };
}
async function fetchAvailableVideoStreams(videoId, accessToken, userAgent = DEFAULT_USER_AGENT) {
if (!videoId || !accessToken) {
throw new Error('videoId and accessToken are required to fetch video streams.');
}
const apiUrl = `${TIDAL_API_BASE_URL}/videos/${videoId}/playbackinfo?videoquality=${DEFAULT_PLAYBACKINFO_VIDEO_QUALITY}&playbackmode=STREAM&assetpresentation=FULL`;
const playbackInfoHeaders = {
'Authorization': `Bearer ${accessToken}`,
};
console.log(`Requesting playback info for video ${videoId} to list available streams (URL: ${apiUrl})...`);
const response = await axios.get(apiUrl, { headers: playbackInfoHeaders });
const responseData = response.data;
if (!responseData?.manifest) {
const detail = responseData?.userMessage || 'Manifest not found in API response for video streams.';
throw new Error(detail);
}
const manifestBase64 = responseData.manifest;
const manifestJsonString = Buffer.from(manifestBase64, 'base64').toString('utf8');
const manifestJson = JSON.parse(manifestJsonString);
if (!manifestJson.urls || manifestJson.urls.length === 0) {
throw new Error('Master M3U8 URL not found in video manifest.');
}
const masterM3U8Url = manifestJson.urls[0];
console.log(`Fetching master M3U8 playlist: ${masterM3U8Url}`);
const m3u8Headers = {
'User-Agent': userAgent,
'Authorization': `Bearer ${accessToken}`, // Some M3U8s might be protected
};
const masterPlaylistResponse = await axios.get(masterM3U8Url, { headers: m3u8Headers });
const masterPlaylistContent = masterPlaylistResponse.data;
const availableStreams = parseM3U8MasterPlaylist(masterPlaylistContent);
if (availableStreams.length === 0) {
throw new Error('No video streams parsed from master playlist.');
}
return availableStreams;
}
async function scrapeVideoTitleFromUrl(url, userAgent = DEFAULT_USER_AGENT) {
try {
console.log(`Scraping title from URL: ${url}`);
const response = await axios.get(url, { headers: { 'User-Agent': userAgent } });
const htmlContent = response.data;
const titleMatch = htmlContent.match(/<title>(.*?)<\/title>/i);
if (titleMatch && titleMatch[1]) {
let title = titleMatch[1].replace(/\s*on TIDAL$/i, '').trim();
return sanitizeForFilename(title);
}
console.log('No title tag found or title was empty.');
return null;
} catch (error) {
console.warn(`Failed to scrape title from ${url}: ${error.message}. Proceeding without scraped title.`);
return null;
}
}
async function internalAskQuestion(query) {
const rlInterface = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(resolve => rlInterface.question(query, ans => {
rlInterface.close();
resolve(ans);
}));
}
async function determineOutputFilenameAndTempBase(videoId, selectedStreamUrl, tidalUrl, userAgent) {
let outputBasename;
let tempDirIdentifier;
if (tidalUrl) {
const scrapedTitle = await scrapeVideoTitleFromUrl(tidalUrl, userAgent);
if (scrapedTitle) {
const proposedFilename = `${scrapedTitle}.ts`;
const useScraped = await internalAskQuestion(`Use "${proposedFilename}" as filename? (y/n, default y): `);
if (useScraped.toLowerCase() !== 'n') {
outputBasename = proposedFilename;
tempDirIdentifier = scrapedTitle; // Use the unsanitized for broader uniqueness if possible
}
}
}
if (!outputBasename) {
let qualityTag = 'selected_quality';
try {
const urlParts = selectedStreamUrl.split('/');
const qualityPart = urlParts.find(part => part.match(/^\d+p$/i) || part.match(/^\d+k$/i));
if (qualityPart) {
qualityTag = qualityPart;
} else {
const resMatch = selectedStreamUrl.match(/(\d+x\d+)/);
if (resMatch && resMatch[1]) qualityTag = resMatch[1];
}
} catch (e) { /* Ignore errors in heuristic quality tag extraction */ }
outputBasename = sanitizeForFilename(`${videoId}_${qualityTag}.ts`);
tempDirIdentifier = `${videoId}_${qualityTag}`;
}
return { outputBasename: sanitizeForFilename(outputBasename), tempDirIdentifier: sanitizeForFilename(tempDirIdentifier) };
}
async function downloadVideo(options) {
const {
videoId,
accessToken,
selectedStreamUrl,
tidalUrl,
userAgent = DEFAULT_USER_AGENT,
outputDir = '.',
} = options;
if (!videoId || !accessToken || !selectedStreamUrl) {
throw new Error('videoId, accessToken, and selectedStreamUrl are mandatory options.');
}
const { outputBasename, tempDirIdentifier } = await determineOutputFilenameAndTempBase(videoId, selectedStreamUrl, tidalUrl, userAgent);
const outputFilePath = path.resolve(outputDir, outputBasename);
const tempDirPath = path.resolve(outputDir, `${TEMP_DIR_VIDEO_PREFIX}_${tempDirIdentifier}_${Date.now()}`);
let aria2cInputFilePath = '';
try {
console.log(`Fetching media playlist for selected quality: ${selectedStreamUrl}`);
const mediaPlaylistResponse = await axios.get(selectedStreamUrl, { headers: { 'User-Agent': userAgent }});
const mediaPlaylistContent = mediaPlaylistResponse.data;
const { urls: segmentUrls, filenames: segmentFilenames } = parseM3U8MediaPlaylist(mediaPlaylistContent);
if (segmentUrls.length === 0) {
throw new Error('No video segments found in the selected media playlist.');
}
console.log(`Found ${segmentUrls.length} video segments.`);
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(tempDirPath, { recursive: true });
console.log(`Temporary directory created: ${tempDirPath}`);
aria2cInputFilePath = path.join(tempDirPath, 'segment_urls.txt');
const aria2cUrlsWithHeaders = segmentUrls.map(url => `${url}\n header=User-Agent: ${userAgent}`);
await fs.writeFile(aria2cInputFilePath, aria2cUrlsWithHeaders.join('\n'));
console.log('Generated URL list with headers for aria2c.');
const aria2cCommand = `aria2c ${ARIA2C_VIDEO_OPTIONS} -d "${tempDirPath}" -i "${aria2cInputFilePath}"`;
console.log('Starting video download with aria2c...');
console.log(`Executing: ${aria2cCommand}`);
await execAsync(aria2cCommand);
console.log('aria2c video download process completed.');
console.log(`Concatenating ${segmentFilenames.length} segments into ${outputFilePath}...`);
const outputStream = createWriteStream(outputFilePath);
try {
for (const segmentName of segmentFilenames) {
const segmentPath = path.join(tempDirPath, segmentName);
try {
await fs.access(segmentPath); // Check existence before reading
const segmentData = await fs.readFile(segmentPath);
outputStream.write(segmentData);
} catch (readError) {
console.warn(`Segment ${segmentPath} not found or unreadable: ${readError.message}. Skipping.`);
}
}
} finally {
outputStream.end();
}
await new Promise((resolve, reject) => {
outputStream.on('finish', resolve);
outputStream.on('error', (err) => reject(new Error(`Error writing to output file ${outputFilePath}: ${err.message}`)));
});
console.log(`Successfully created video file: ${outputFilePath}`);
return { success: true, filePath: outputFilePath };
} catch (error) {
let errorMessage = `Error during download for video ${videoId}: ${error.message}`;
if (axios.isAxiosError(error)) {
errorMessage = `API request failed for video ${videoId}. Status: ${error.response?.status || 'unknown'}.`;
if(error.response?.data){
errorMessage += ` Detail: ${typeof error.response.data === 'string' ? error.response.data.substring(0,300) : JSON.stringify(error.response.data).substring(0,300)}`;
}
} else if (error.stderr || error.stdout) {
errorMessage += `\n aria2c Stderr: ${error.stderr}\n aria2c Stdout: ${error.stdout}`;
}
console.error(errorMessage);
throw error;
} finally {
if (tempDirPath) {
try {
await fs.stat(tempDirPath);
console.log(`Cleaning up temporary directory: ${tempDirPath}`);
await fs.rm(tempDirPath, { recursive: true, force: true });
console.log('Temporary directory cleanup complete.');
} catch (cleanupError) {
if (cleanupError.code !== 'ENOENT') {
console.error(`Failed to cleanup temporary directory ${tempDirPath}: ${cleanupError.message}`);
}
}
}
}
}
export { downloadVideo, fetchAvailableVideoStreams };