mirror of
https://github.com/AndresDevvv/Tidal-DL.git
synced 2026-01-15 08:22:56 -03:00
optimize & hala madrid y nada mas
This commit is contained in:
512
startup.mjs
512
startup.mjs
@@ -4,34 +4,107 @@ import readline from 'readline';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import axios from 'axios';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { authenticate } from './v2/login.mjs';
|
||||
import musicModule from './v2/music.js';
|
||||
const { downloadMusicTrack } = musicModule;
|
||||
import videoModule from './v2/video.js';
|
||||
const { downloadVideo, fetchAvailableVideoStreams } = videoModule;
|
||||
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));
|
||||
}
|
||||
|
||||
function extractIdFromUrl(url, expectedType) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const regex = new RegExp(`\/(?:browse\/)?${expectedType}\/(\\d+)`);
|
||||
const match = url.match(regex);
|
||||
async function promptUserForSelection(promptMessage, options, optionFormatter = (opt) => opt.name || opt) {
|
||||
console.log(`\n${promptMessage}`);
|
||||
options.forEach((option, index) => {
|
||||
console.log(` ${index + 1}. ${optionFormatter(option)}`);
|
||||
});
|
||||
|
||||
if (match && match[1]) {
|
||||
return { type: expectedType, id: match[1] };
|
||||
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 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) {
|
||||
@@ -42,9 +115,6 @@ async function fetchHtmlContent(url) {
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Host': urlObj.hostname,
|
||||
'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-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
@@ -53,53 +123,59 @@ async function fetchHtmlContent(url) {
|
||||
};
|
||||
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error(`[fetchHtmlContent] Axios error fetching ${url}: ${error.message}`);
|
||||
console.error(`Error fetching HTML from ${url}: ${error.message}`);
|
||||
if (error.response) {
|
||||
console.error(`[fetchHtmlContent] Status: ${error.response.status}`);
|
||||
console.error(`[fetchHtmlContent] Data (first 200): ${String(error.response.data).substring(0,200)}`);
|
||||
console.error(`Status: ${error.response.status}, Data (first 200 chars): ${String(error.response.data).substring(0, 200)}`);
|
||||
} else if (error.request) {
|
||||
console.error('[fetchHtmlContent] No response received for URL:', url);
|
||||
console.error('No response received from server.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOgMeta(htmlContent, property) {
|
||||
function parseOgMetaProperty(htmlContent, property) {
|
||||
const regex = new RegExp(`<meta[^>]*property="og:${property}"[^>]*content="([^"]+)"`, 'i');
|
||||
const match = htmlContent.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function fetchSongMetadataForRenaming(trackUrl) {
|
||||
function normalizeTidalTrackUrlForMetadata(inputUrlStr) {
|
||||
try {
|
||||
let browseTrackUrl = trackUrl;
|
||||
const urlObjInput = new URL(trackUrl);
|
||||
const url = new URL(inputUrlStr);
|
||||
let path = url.pathname;
|
||||
|
||||
if (urlObjInput.pathname.startsWith('/u/')) {
|
||||
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 (path.startsWith('/u/')) path = path.substring(2);
|
||||
|
||||
if (trackSegmentIndex !== -1 && pathSegments.length > trackSegmentIndex + 1) {
|
||||
trackIdFromPath = pathSegments[trackSegmentIndex + 1];
|
||||
browseTrackUrl = `${urlObjInput.protocol}//${urlObjInput.host}/browse/track/${trackIdFromPath}`;
|
||||
} else {
|
||||
console.warn(`[fetchSongMetadataForRenaming] Could not normalize to a /browse/track/ URL from: ${trackUrl}. Using it as is for fetching.`);
|
||||
browseTrackUrl = trackUrl;
|
||||
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];
|
||||
}
|
||||
|
||||
console.log(`[fetchSongMetadataForRenaming] Fetching HTML from (normalized): ${browseTrackUrl}`);
|
||||
const htmlContent = await fetchHtmlContent(browseTrackUrl);
|
||||
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;
|
||||
|
||||
@@ -107,279 +183,245 @@ async function fetchSongMetadataForRenaming(trackUrl) {
|
||||
if (pageTitleTagMatch && pageTitleTagMatch[1] && pageTitleTagMatch[2]) {
|
||||
title = pageTitleTagMatch[1].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 {
|
||||
console.log(`[fetchSongMetadataForRenaming] Could not parse <title> tag. Trying og:title.`);
|
||||
const ogTitle = parseOgMeta(htmlContent, 'title');
|
||||
const ogTitle = parseOgMetaProperty(htmlContent, 'title');
|
||||
if (ogTitle) {
|
||||
const parts = ogTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
title = parts[0].trim();
|
||||
title = parts[0].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 {
|
||||
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 };
|
||||
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFilename(name) {
|
||||
function sanitizeFilenameSegment(name) {
|
||||
if (!name || typeof name !== 'string') return '';
|
||||
let sanitized = name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
|
||||
sanitized = sanitized.replace(/\s+/g, ' ');
|
||||
sanitized = sanitized.trim();
|
||||
if (sanitized === '' || sanitized.match(/^\.+$/)) {
|
||||
return 'untitled';
|
||||
}
|
||||
return sanitized.substring(0, 200);
|
||||
sanitized = sanitized.replace(/\s+/g, ' ').trim();
|
||||
return (sanitized === '' || sanitized.match(/^\.+$/)) ? 'untitled' : sanitized;
|
||||
}
|
||||
|
||||
async function handleSongRenaming(originalSongFilePath, songUrl) {
|
||||
async function renameDownloadedSong(originalFilePath, songUrl) {
|
||||
console.log("Fetching metadata for potential renaming...");
|
||||
const metadata = await fetchSongMetadataForRenaming(songUrl);
|
||||
const { title, artist } = await fetchTrackMetadataForRenaming(songUrl);
|
||||
|
||||
let finalFilePath = originalSongFilePath;
|
||||
|
||||
if (metadata.title && metadata.artist) {
|
||||
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.");
|
||||
if (!title || !artist) {
|
||||
console.log("Skipping renaming: insufficient metadata (title/artist missing).");
|
||||
return originalFilePath;
|
||||
}
|
||||
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 = [
|
||||
{ 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 fileExt = path.extname(originalFilePath);
|
||||
const outputDir = path.dirname(originalFilePath);
|
||||
const newBaseName = `${sanitizeFilenameSegment(artist)} - ${sanitizeFilenameSegment(title)}`;
|
||||
let newFilePath = path.join(outputDir, `${newBaseName}${fileExt}`);
|
||||
|
||||
async function selectAudioQuality() {
|
||||
console.log("\nAvailable Audio Qualities:");
|
||||
AUDIO_QUALITIES.forEach((quality, index) => {
|
||||
console.log(` ${index + 1}. ${quality.name} (API Code: ${quality.apiCode})`);
|
||||
});
|
||||
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;
|
||||
|
||||
let choiceIndex = -1;
|
||||
while (choiceIndex < 0 || choiceIndex >= AUDIO_QUALITIES.length) {
|
||||
const answer = await askQuestion(`Select quality (1-${AUDIO_QUALITIES.length}): `);
|
||||
const parsedAnswer = parseInt(answer, 10);
|
||||
if (!isNaN(parsedAnswer) && parsedAnswer >= 1 && parsedAnswer <= AUDIO_QUALITIES.length) {
|
||||
choiceIndex = parsedAnswer - 1;
|
||||
if (newBaseName.length > excessLength) {
|
||||
const truncatedBaseName = newBaseName.substring(0, newBaseName.length - excessLength - 3) + "...";
|
||||
newFilePath = path.join(outputDir, `${truncatedBaseName}${fileExt}`);
|
||||
} 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...");
|
||||
let streams;
|
||||
|
||||
if (newFilePath === originalFilePath) {
|
||||
console.log("Generated filename is same as original. No rename needed.");
|
||||
return originalFilePath;
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error("Error fetching video qualities:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!streams || streams.length === 0) {
|
||||
console.log("No video streams found or an error occurred.");
|
||||
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}`);
|
||||
|
||||
console.log("\nAvailable Video Qualities (sorted best first by bandwidth):");
|
||||
streams.forEach((stream, index) => {
|
||||
console.log(` ${index + 1}. Resolution: ${stream.resolution}, Bandwidth: ${stream.bandwidth} bps, Codecs: ${stream.codecs}`);
|
||||
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
|
||||
});
|
||||
|
||||
let choiceIndex = -1;
|
||||
while (choiceIndex < 0 || choiceIndex >= streams.length) {
|
||||
const answer = await askQuestion(`Select quality (1-${streams.length}): `);
|
||||
const parsedAnswer = parseInt(answer, 10);
|
||||
if (!isNaN(parsedAnswer) && parsedAnswer >= 1 && parsedAnswer <= streams.length) {
|
||||
choiceIndex = parsedAnswer - 1;
|
||||
} else {
|
||||
console.log("Invalid selection. Please enter a number from the list.");
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
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() {
|
||||
console.log("╔═════════════════════════════════════════════════╗");
|
||||
console.log("║ Welcome to Tidal Downloader! ║");
|
||||
console.log("╚═════════════════════════════════════════════════╝");
|
||||
console.log("\nMake sure you have 'aria2c' installed and in your system's PATH.");
|
||||
console.log("Downloads will be saved in a './downloads' directory relative to this script.");
|
||||
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("\nAttempting to authenticate with Tidal...");
|
||||
console.log(UI_TEXT.AUTHENTICATING_MSG);
|
||||
session = await authenticate();
|
||||
} catch (error) {
|
||||
console.error("\nFatal error during the authentication process:", error.message);
|
||||
console.error("\nFatal error during authentication:", error.message);
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session || !session.isAccessTokenValid()) {
|
||||
console.error("\nAuthentication failed, or no valid session obtained. Cannot proceed.");
|
||||
console.log("Please ensure you complete the device authorization if prompted.");
|
||||
if (!session || !session.isAccessTokenCurrentlyValid()) {
|
||||
console.error(UI_TEXT.AUTH_FAILED_MSG);
|
||||
console.log(UI_TEXT.AUTH_RETRY_PROMPT);
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
console.log("\n✅ Successfully authenticated with Tidal!");
|
||||
console.log(UI_TEXT.AUTH_SUCCESS_MSG);
|
||||
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) {
|
||||
console.log("\n---------------------------------------------");
|
||||
console.log("What would you like to do?");
|
||||
console.log(" 1. Download a Song");
|
||||
console.log(" 2. Download a Music Video");
|
||||
console.log(" 3. Exit");
|
||||
console.log(UI_TEXT.SEPARATOR_LINE);
|
||||
const choice = await promptUserForSelection(UI_TEXT.MAIN_MENU_PROMPT, MAIN_MENU_OPTIONS);
|
||||
|
||||
let choice = '';
|
||||
while (choice !== '1' && choice !== '2' && choice !== '3') {
|
||||
choice = await askQuestion("Enter your choice (1-3): ");
|
||||
if (choice !== '1' && choice !== '2' && choice !== '3') {
|
||||
console.log("Invalid choice. Please enter 1, 2, or 3.");
|
||||
}
|
||||
if (choice.id === 'EXIT') {
|
||||
console.log(UI_TEXT.EXIT_MESSAGE);
|
||||
break;
|
||||
}
|
||||
|
||||
if (choice === '3') {
|
||||
console.log("\nExiting. Goodbye! 👋");
|
||||
break mainLoop;
|
||||
}
|
||||
|
||||
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);
|
||||
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 ${idType} ID from the URL provided.`);
|
||||
console.error(` Please ensure the URL is correct and matches the format: ${exampleUrl}`);
|
||||
console.error(`\n❌ Could not extract a ${currentItemType} ID from URL: ${itemUrl}`);
|
||||
console.error(` Ensure URL format is like: ${exampleUrl}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemId = idInfo.id;
|
||||
console.log(`\n🆔 Extracted ${idInfo.type} ID: ${itemId}`);
|
||||
console.log(`\n🆔 Extracted ${idInfo.type} ID: ${idInfo.id}`);
|
||||
|
||||
let outputDir;
|
||||
try {
|
||||
if (isSong) {
|
||||
const selectedQuality = await selectAudioQuality();
|
||||
if (!selectedQuality) {
|
||||
console.log("No audio quality selected. Aborting download.");
|
||||
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.`);
|
||||
if (currentItemType === ITEM_TYPE.SONG) {
|
||||
await handleSongDownload(session, itemUrl, idInfo.id);
|
||||
} else {
|
||||
await handleVideoDownload(session, itemUrl, idInfo.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`\n❌ An error occurred during the download of ${downloadType} ID ${itemId}.`);
|
||||
console.error(` Specific error: ${error.message}`);
|
||||
console.error(`\n❌ Error during download of ${currentItemType} ID ${idInfo.id}: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
}
|
||||
|
||||
let another = '';
|
||||
while (another !== 'yes' && another !== 'y' && another !== 'no' && another !== 'n') {
|
||||
another = (await askQuestion("\nDo you want to download another item? (yes/no): ")).toLowerCase().trim();
|
||||
}
|
||||
if (another === 'no' || another === 'n') {
|
||||
console.log("\nExiting. Goodbye! 👋");
|
||||
break mainLoop;
|
||||
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🚨 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);
|
||||
if (rl && typeof rl.close === 'function') rl.close();
|
||||
if (rl && typeof rl.close === 'function') {
|
||||
rl.close();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
332
v2/login.mjs
332
v2/login.mjs
@@ -1,282 +1,298 @@
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const API_CLIENT = {
|
||||
const API_CLIENT_CONFIG = {
|
||||
clientId: '7m7Ap0JC9j1cOM3n',
|
||||
clientSecret: 'vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=',
|
||||
scope: 'r_usr w_usr w_sub'
|
||||
};
|
||||
|
||||
const AUTH_URL_BASE = 'https://auth.tidal.com/v1/oauth2';
|
||||
const SESSION_STORAGE_FILE = 'tidal_session.json';
|
||||
const TIDAL_AUTH_BASE_URL = 'https://auth.tidal.com/v1/oauth2';
|
||||
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 {
|
||||
constructor(initialData = {}) {
|
||||
this.deviceCode = initialData.deviceCode || null;
|
||||
this.userCode = initialData.userCode || null;
|
||||
this.verificationUrl = initialData.verificationUrl || null;
|
||||
this.authCheckTimeout = initialData.authCheckTimeout || null;
|
||||
this.authCheckInterval = initialData.authCheckInterval || null;
|
||||
this.userId = initialData.userId || null;
|
||||
this.countryCode = initialData.countryCode || null;
|
||||
this.accessToken = initialData.accessToken || null;
|
||||
this.refreshToken = initialData.refreshToken || null;
|
||||
this.tokenExpiresAt = initialData.tokenExpiresAt || null;
|
||||
class TidalAuthSession {
|
||||
constructor(initialSessionData = {}) {
|
||||
const defaults = {
|
||||
deviceCode: null,
|
||||
userCode: null,
|
||||
verificationUrl: null,
|
||||
authCheckTimeoutTimestamp: null,
|
||||
authCheckIntervalMs: null,
|
||||
userId: null,
|
||||
countryCode: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiresAtTimestamp: null,
|
||||
};
|
||||
Object.assign(this, defaults, initialSessionData);
|
||||
}
|
||||
|
||||
isAccessTokenValid() {
|
||||
return this.accessToken && this.tokenExpiresAt && Date.now() < this.tokenExpiresAt;
|
||||
isAccessTokenCurrentlyValid() {
|
||||
return this.accessToken && this.tokenExpiresAtTimestamp && Date.now() < this.tokenExpiresAtTimestamp;
|
||||
}
|
||||
|
||||
hasRefreshToken() {
|
||||
hasValidRefreshToken() {
|
||||
return !!this.refreshToken;
|
||||
}
|
||||
|
||||
updateTokens(tokenResponse) {
|
||||
this.accessToken = tokenResponse.access_token;
|
||||
if (tokenResponse.refresh_token) {
|
||||
this.refreshToken = tokenResponse.refresh_token;
|
||||
updateAccessTokens(tokenApiResponse) {
|
||||
this.accessToken = tokenApiResponse.access_token;
|
||||
if (tokenApiResponse.refresh_token) {
|
||||
this.refreshToken = tokenApiResponse.refresh_token;
|
||||
}
|
||||
this.tokenExpiresAt = Date.now() + (tokenResponse.expires_in * 1000);
|
||||
if (tokenResponse.user) {
|
||||
this.userId = tokenResponse.user.userId;
|
||||
this.countryCode = tokenResponse.user.countryCode;
|
||||
this.tokenExpiresAtTimestamp = Date.now() + (tokenApiResponse.expires_in * 1000);
|
||||
if (tokenApiResponse.user) {
|
||||
this.userId = tokenApiResponse.user.userId;
|
||||
this.countryCode = tokenApiResponse.user.countryCode;
|
||||
}
|
||||
}
|
||||
|
||||
clearAuthDetails() {
|
||||
invalidateCurrentTokens() {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.tokenExpiresAt = null;
|
||||
this.tokenExpiresAtTimestamp = null;
|
||||
this.userId = null;
|
||||
this.countryCode = null;
|
||||
}
|
||||
|
||||
clearDeviceAuthDetails() {
|
||||
clearActiveDeviceAuthParameters() {
|
||||
this.deviceCode = null;
|
||||
this.userCode = null;
|
||||
this.verificationUrl = null;
|
||||
this.authCheckTimeout = null;
|
||||
this.authCheckInterval = null;
|
||||
this.authCheckTimeoutTimestamp = null;
|
||||
this.authCheckIntervalMs = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSession(session) {
|
||||
const dataToSave = {
|
||||
userId: session.userId,
|
||||
countryCode: session.countryCode,
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
tokenExpiresAt: session.tokenExpiresAt,
|
||||
async function persistSession(sessionInstance) {
|
||||
const dataToPersist = {
|
||||
userId: sessionInstance.userId,
|
||||
countryCode: sessionInstance.countryCode,
|
||||
accessToken: sessionInstance.accessToken,
|
||||
refreshToken: sessionInstance.refreshToken,
|
||||
tokenExpiresAtTimestamp: sessionInstance.tokenExpiresAtTimestamp,
|
||||
};
|
||||
try {
|
||||
await fs.writeFile(SESSION_STORAGE_FILE, JSON.stringify(dataToSave, null, 2));
|
||||
console.log(`Session saved to ${SESSION_STORAGE_FILE}`);
|
||||
await fs.writeFile(SESSION_PERSISTENCE_FILE, JSON.stringify(dataToPersist, null, 2));
|
||||
console.log(`Session data saved to ${SESSION_PERSISTENCE_FILE}`);
|
||||
} 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 {
|
||||
const data = await fs.readFile(SESSION_STORAGE_FILE, 'utf8');
|
||||
const loadedData = JSON.parse(data);
|
||||
console.log(`Session loaded from ${SESSION_STORAGE_FILE}`);
|
||||
return new TidalSession(loadedData);
|
||||
const rawData = await fs.readFile(SESSION_PERSISTENCE_FILE, 'utf8');
|
||||
const loadedSessionData = JSON.parse(rawData);
|
||||
console.log(`Session data loaded from ${SESSION_PERSISTENCE_FILE}`);
|
||||
return new TidalAuthSession(loadedSessionData);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.warn(`Could not load session from ${SESSION_STORAGE_FILE}: ${error.message}. A new session will be created.`);
|
||||
if (error.code === 'ENOENT') {
|
||||
console.log(`No session file found (${SESSION_PERSISTENCE_FILE}). A new session will be initiated.`);
|
||||
} 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) {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
async function fetchWithRetries(url, fetchOptions, maxRetries = DEFAULT_FETCH_MAX_RETRIES) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (response.status === 429) {
|
||||
const retryAfter = parseInt(response.headers.get('Retry-After') || "20", 10);
|
||||
console.warn(`Rate limit hit (429) for ${url}. Retrying after ${retryAfter} seconds... (Attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
|
||||
const retryAfterSeconds = parseInt(response.headers.get('Retry-After') || String(DEFAULT_HTTP_RETRY_DELAY_SECONDS), 10);
|
||||
console.warn(`Rate limit hit for ${url}. Retrying after ${retryAfterSeconds}s. (Attempt ${attempt}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000));
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.warn(`Fetch attempt ${attempt + 1}/${maxRetries} failed for ${url}: ${error.message}`);
|
||||
if (attempt === maxRetries - 1) throw error;
|
||||
await new Promise(resolve => setTimeout(resolve, 2000 * (attempt + 1)));
|
||||
console.warn(`Fetch attempt ${attempt}/${maxRetries} for ${url} failed: ${error.message}`);
|
||||
if (attempt === maxRetries) {
|
||||
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) {
|
||||
console.log("Requesting new device code...");
|
||||
const body = new URLSearchParams({
|
||||
client_id: API_CLIENT.clientId,
|
||||
scope: API_CLIENT.scope
|
||||
async function parseErrorResponse(response) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString()
|
||||
body: requestBody.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch (e) {
|
||||
errorData = { message: response.statusText };
|
||||
}
|
||||
throw new Error(`Failed to get device code: ${response.status} - ${errorData.userMessage || errorData.message || 'Unknown error'}`);
|
||||
const errorDetails = await parseErrorResponse(response);
|
||||
const errorMessage = errorDetails.userMessage || errorDetails.message || errorDetails.messageFromStatus || 'Unknown server error';
|
||||
throw new Error(`Failed to request device code: ${response.status} - ${errorMessage}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
session.deviceCode = data.deviceCode;
|
||||
session.userCode = data.userCode;
|
||||
session.verificationUrl = data.verificationUriComplete || data.verificationUri;
|
||||
session.authCheckTimeout = Date.now() + (data.expiresIn * 1000);
|
||||
session.authCheckInterval = data.interval * 1000;
|
||||
const responseData = await response.json();
|
||||
sessionInstance.deviceCode = responseData.deviceCode;
|
||||
sessionInstance.userCode = responseData.userCode;
|
||||
sessionInstance.verificationUrl = responseData.verificationUriComplete || responseData.verificationUri;
|
||||
sessionInstance.authCheckTimeoutTimestamp = Date.now() + (responseData.expiresIn * 1000);
|
||||
sessionInstance.authCheckIntervalMs = responseData.interval * 1000;
|
||||
|
||||
console.log("\n---------------------------------------------------------------------");
|
||||
console.log(" TIDAL DEVICE AUTHENTICATION REQUIRED");
|
||||
console.log("---------------------------------------------------------------------");
|
||||
console.log(`1. Open your web browser and go to: https://${session.verificationUrl}`);
|
||||
console.log(`2. Enter the following code: ${session.userCode} if asked`);
|
||||
console.log("---------------------------------------------------------------------");
|
||||
console.log("\nWaiting for authorization (this may take a moment)...");
|
||||
console.log("\n--- TIDAL DEVICE AUTHENTICATION REQUIRED ---");
|
||||
console.log(`1. Open a web browser and go to: https://${sessionInstance.verificationUrl}`);
|
||||
console.log(`2. If prompted, enter this code: ${sessionInstance.userCode}`);
|
||||
console.log("--- WAITING FOR AUTHORIZATION ---");
|
||||
}
|
||||
|
||||
async function pollForToken(session) {
|
||||
const basicAuth = `Basic ${Buffer.from(`${API_CLIENT.clientId}:${API_CLIENT.clientSecret}`).toString('base64')}`;
|
||||
const body = new URLSearchParams({
|
||||
client_id: API_CLIENT.clientId,
|
||||
device_code: session.deviceCode,
|
||||
async function pollForDeviceAccessToken(sessionInstance) {
|
||||
const basicAuthToken = createBasicAuthHeader(API_CLIENT_CONFIG.clientId, API_CLIENT_CONFIG.clientSecret);
|
||||
const requestBody = new URLSearchParams({
|
||||
client_id: API_CLIENT_CONFIG.clientId,
|
||||
device_code: sessionInstance.deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
scope: API_CLIENT.scope
|
||||
scope: API_CLIENT_CONFIG.scope
|
||||
});
|
||||
|
||||
while (Date.now() < session.authCheckTimeout) {
|
||||
await new Promise(resolve => setTimeout(resolve, session.authCheckInterval));
|
||||
process.stdout.write("Polling for authorization");
|
||||
while (Date.now() < sessionInstance.authCheckTimeoutTimestamp) {
|
||||
await new Promise(resolve => setTimeout(resolve, sessionInstance.authCheckIntervalMs));
|
||||
process.stdout.write(".");
|
||||
|
||||
const response = await fetchWithRetry(`${AUTH_URL_BASE}/token`, {
|
||||
const response = await fetchWithRetries(`${TIDAL_AUTH_BASE_URL}/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': basicAuth
|
||||
'Authorization': basicAuthToken
|
||||
},
|
||||
body: body.toString()
|
||||
body: requestBody.toString()
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const tokenData = await response.json();
|
||||
session.updateTokens(tokenData);
|
||||
console.log("\nAuthorization successful!");
|
||||
await saveSession(session);
|
||||
session.clearDeviceAuthDetails();
|
||||
sessionInstance.updateAccessTokens(tokenData);
|
||||
process.stdout.write("\n");
|
||||
console.log("Authorization successful!");
|
||||
await persistSession(sessionInstance);
|
||||
sessionInstance.clearActiveDeviceAuthParameters();
|
||||
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) {
|
||||
// Authorization pending, continue polling
|
||||
} else {
|
||||
console.error(`\nError polling for token: ${errorData.status} - ${errorData.sub_status || ''} - ${errorData.userMessage || errorData.error_description || 'Unknown error'}`);
|
||||
session.clearDeviceAuthDetails();
|
||||
return false;
|
||||
}
|
||||
const errorData = await parseErrorResponse(response);
|
||||
if (errorData.status === 400 && errorData.sub_status === 1002) {
|
||||
continue;
|
||||
} else {
|
||||
process.stdout.write("\n");
|
||||
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.");
|
||||
session.clearDeviceAuthDetails();
|
||||
process.stdout.write("\n");
|
||||
console.log("Device-code authorization timed out.");
|
||||
sessionInstance.clearActiveDeviceAuthParameters();
|
||||
return false;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(session) {
|
||||
if (!session.hasRefreshToken()) {
|
||||
console.log("No refresh token available to refresh session.");
|
||||
async function attemptTokenRefresh(sessionInstance) {
|
||||
if (!sessionInstance.hasValidRefreshToken()) {
|
||||
console.log("No refresh token available. Cannot refresh session.");
|
||||
return false;
|
||||
}
|
||||
console.log("Attempting to refresh access token...");
|
||||
const basicAuth = `Basic ${Buffer.from(`${API_CLIENT.clientId}:${API_CLIENT.clientSecret}`).toString('base64')}`;
|
||||
const body = new URLSearchParams({
|
||||
client_id: API_CLIENT.clientId,
|
||||
refresh_token: session.refreshToken,
|
||||
const basicAuthToken = createBasicAuthHeader(API_CLIENT_CONFIG.clientId, API_CLIENT_CONFIG.clientSecret);
|
||||
const requestBody = new URLSearchParams({
|
||||
client_id: API_CLIENT_CONFIG.clientId,
|
||||
refresh_token: sessionInstance.refreshToken,
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': basicAuth
|
||||
'Authorization': basicAuthToken
|
||||
},
|
||||
body: body.toString()
|
||||
body: requestBody.toString()
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const tokenData = await response.json();
|
||||
session.updateTokens(tokenData);
|
||||
sessionInstance.updateAccessTokens(tokenData);
|
||||
console.log("Access token refreshed successfully.");
|
||||
await saveSession(session);
|
||||
await persistSession(sessionInstance);
|
||||
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() {
|
||||
let session = await loadSession();
|
||||
async function establishAuthenticatedSession() {
|
||||
let currentSession = await retrievePersistedSession();
|
||||
|
||||
if (session.isAccessTokenValid()) {
|
||||
console.log("Found valid access token in session. Authentication successful (using existing session).");
|
||||
return session;
|
||||
if (currentSession.isAccessTokenCurrentlyValid()) {
|
||||
console.log("Valid access token found. Authentication successful (using existing session).");
|
||||
return currentSession;
|
||||
}
|
||||
|
||||
if (session.hasRefreshToken()) {
|
||||
if (currentSession.hasValidRefreshToken()) {
|
||||
console.log("Access token expired or invalid. Attempting to use refresh token.");
|
||||
if (await refreshAccessToken(session)) {
|
||||
console.log("Authentication successful (after refreshing token).");
|
||||
return session;
|
||||
if (await attemptTokenRefresh(currentSession)) {
|
||||
console.log("Authentication successful (token refreshed).");
|
||||
return currentSession;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("No valid session found or refresh failed. Starting new device authentication flow.");
|
||||
session.clearAuthDetails();
|
||||
console.log("No valid session or refresh failed. Initiating new device authentication.");
|
||||
currentSession.invalidateCurrentTokens();
|
||||
try {
|
||||
await getDeviceCode(session);
|
||||
if (await pollForToken(session)) {
|
||||
await requestDeviceCode(currentSession);
|
||||
if (await pollForDeviceAccessToken(currentSession)) {
|
||||
console.log("Authentication successful (new device authorization).");
|
||||
return session;
|
||||
return currentSession;
|
||||
} else {
|
||||
console.error("Device authentication process failed or timed out.");
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Device authentication flow error: ${error.message}`);
|
||||
console.error(`Device authentication flow encountered an error: ${error.message}`);
|
||||
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
|
||||
};
|
||||
159
v2/music.js
159
v2/music.js
@@ -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
184
v2/music.mjs
Normal 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 };
|
||||
279
v2/video.js
279
v2/video.js
@@ -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
279
v2/video.mjs
Normal 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 };
|
||||
Reference in New Issue
Block a user