diff --git a/startup.mjs b/startup.mjs
index cbd11cf..478a620 100644
--- a/startup.mjs
+++ b/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(`]*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
tag - Title: "${title}", Artist: "${artist}"`);
+ console.log(`Metadata from : Title "${title}", Artist "${artist}"`);
} else {
- console.log(`[fetchSongMetadataForRenaming] Could not parse 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 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);
});
\ No newline at end of file
diff --git a/v2/login.mjs b/v2/login.mjs
index 5160544..95babe0 100644
--- a/v2/login.mjs
+++ b/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 };
\ No newline at end of file
+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
+};
\ No newline at end of file
diff --git a/v2/music.js b/v2/music.js
deleted file mode 100644
index 6c032fc..0000000
--- a/v2/music.js
+++ /dev/null
@@ -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 };
\ No newline at end of file
diff --git a/v2/music.mjs b/v2/music.mjs
new file mode 100644
index 0000000..ccb9dbd
--- /dev/null
+++ b/v2/music.mjs
@@ -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 };
\ No newline at end of file
diff --git a/v2/video.js b/v2/video.js
deleted file mode 100644
index 1230432..0000000
--- a/v2/video.js
+++ /dev/null
@@ -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>/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 };
\ No newline at end of file
diff --git a/v2/video.mjs b/v2/video.mjs
new file mode 100644
index 0000000..64556d4
--- /dev/null
+++ b/v2/video.mjs
@@ -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>/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 };
\ No newline at end of file