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 "${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); }); \ 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>(.*?)<\/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>(.*?)<\/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