first commit

This commit is contained in:
AndresDev
2025-05-11 06:47:16 -04:00
commit 95ec9d8cf5
8 changed files with 1592 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/node_modules/
/downloads/
tidal_session.json

323
package-lock.json generated Normal file
View File

@@ -0,0 +1,323 @@
{
"name": "tidal-dl",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tidal-dl",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"axios": "^1.9.0",
"xml2js": "^0.6.2"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"license": "ISC"
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
}
}
}

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "tidal-dl",
"version": "1.0.0",
"description": "A very simple tidal downloader that uses aria2c to download.",
"main": "startup.mjs",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"startup": "node startup.mjs"
},
"author": "AndresDev",
"license": "ISC",
"dependencies": {
"axios": "^1.9.0",
"xml2js": "^0.6.2"
}
}

145
readme.md Normal file
View File

@@ -0,0 +1,145 @@
# Tidal-DL 🌊🎵🎬
Download music tracks and music videos directly from Tidal.
---
`Tidal-DL` is a command-line interface (CLI) tool that allows you to download your music and music videos from Tidal. It authenticates with your Tidal account using a secure device authorization flow and saves your session for future use.
## ✨ Features
* **Song Downloads:**
* Choose from available audio qualities:
* Standard (AAC 96 kbps)
* High (AAC 320 kbps)
* HiFi (CD Quality FLAC 16-bit/44.1kHz - Lossless)
* Max (HiRes FLAC up to 24-bit/192kHz - Lossless)
* Downloads are saved as `.flac` files.
* Automatic renaming to `Artist - Title.flac` (with confirmation) based on metadata fetched from Tidal.
* **Music Video Downloads:**
* Lists available video resolutions and bandwidths for you to select the best option.
* Downloads are saved as `.ts` files.
* Attempts to name files using the video title scraped from the Tidal page.
* **Efficient Downloads:** Utilizes `aria2c` for fast, resumable, and segmented downloading.
* **Authentication:** Secure OAuth2 device login. Session details (including access and refresh tokens) are stored locally in `tidal_session.json` for persistence, reducing the need to log in repeatedly.
* **Interactive CLI:** A user-friendly command-line interface guides you through the selection and download process.
## 🚀 Prerequisites
Before you begin, ensure you have the following installed on your system:
1. **Node.js:** A recent LTS version (e.g., v18.x, v20.x, or newer). You can download it from [nodejs.org](https://nodejs.org/).
2. **aria2c:** This is a **critical dependency** for the download process.
* Official website: [aria2.github.io](https://aria2.github.io/)
* Ensure `aria2c` is installed and accessible from your system's PATH (i.e., you can run `aria2c --version` in your terminal).
## 🛠️ Setup
1. **Clone the repository:**
If you're downloading this from GitHub, you've likely already done this or downloaded the source. If not:
```bash
git clone https://github.com/andresdevvv/tidal-dl.git
cd tidal-dl
```
2. **Install dependencies:**
Navigate to the project's root directory in your terminal and run:
```bash
npm install
```
This will install `axios`, `xml2js`.
## ⚙️ How to Use
1. **Run the script:**
Open your terminal in the `Tidal-DL` project directory and execute:
```bash
node run startup
```
2. **First-Time Authentication:**
* On your first run, the script will guide you through the Tidal authentication process.
* You will see a message like this:
```
---------------------------------------------------------------------
TIDAL DEVICE AUTHENTICATION REQUIRED
---------------------------------------------------------------------
1. Open your web browser and go to: https://link.tidal.com/XXXXX
2. Enter the following code: YOUR_USER_CODE if asked
---------------------------------------------------------------------
Waiting for authorization (this may take a moment)...
```
* Open the provided URL (e.g., `https://link.tidal.com/XXXXX`) in your web browser.
* Enter the user code (e.g., `YOUR_USER_CODE`) on the Tidal website if prompted.
* Authorize the application in your browser.
* Once authorized, the script will automatically detect it, complete the login, and save your session details to `tidal_session.json`. Future runs will attempt to use this saved session.
3. **Download Process:**
* After successful authentication, the main menu will appear:
```
What would you like to do?
1. Download a Song
2. Download a Music Video
3. Exit
Enter your choice (1-3):
```
* Enter your choice (`1` for a song, `2` for a video).
* **Provide URL:** Paste the full Tidal URL for the song or music video you want to download.
* **Song URL Examples:**
* `https://tidal.com/browse/track/12345678`
* `https://tidal.com/track/12345678`
* `https://tidal.com/u/SOME_USER_ID/track/12345678` (shared links)
* **Video URL Example:**
* `https://tidal.com/browse/video/87654321`
* The script will extract the ID from the URL.
* **Select Quality:**
* **For Songs:** You'll be presented with a list of available audio qualities (Standard, High, HiFi, Max). Select your preferred quality.
* **For Music Videos:** The script will fetch available video streams and list them by resolution and bandwidth (best first). Select your preferred stream.
* **File Naming Confirmation (Optional):**
* **For Songs:** If metadata (artist, title) is successfully fetched, you'll be asked: `Do you want to rename the file based on Artist - Title? (yes/no):`.
* **For Music Videos:** If a title is successfully scraped from the Tidal page, you might be asked: `Use "Scraped Video Title.ts" as filename? (y/n):`.
* **Download:** The download will start. `aria2c` handles the actual downloading of segments, which are then combined into the final file.
4. **Output Location:**
* Downloaded songs are saved in the `./downloads/music/` directory relative to where you run the script.
* Downloaded music videos are saved in the `./downloads/videos/` directory.
## 📁 File Structure Overview
```
Tidal-DL/
├── downloads/ # Default directory for all downloaded files
│ ├── music/ # Stores downloaded songs (.flac)
│ └── videos/ # Stores downloaded music videos (.ts)
├── node_modules/ # Project dependencies (created by `npm install`)
├── v2/ # Core logic modules
│ ├── login.mjs # Handles Tidal authentication & session management
│ ├── music.js # Logic for music track downloads
│ └── video.js # Logic for music video downloads
├── .gitignore # Specifies intentionally untracked files for Git
├── package-lock.json # Records exact versions of installed dependencies
├── package.json # Project metadata and list of dependencies
├── startup.mjs # The main executable script (CLI entry point)
└── tidal_session.json # Stores your Tidal login session data (created after first login)
```
## ⚠️ Important Notes
* **`aria2c` is Essential:** This tool **will not work** if `aria2c` is not installed or not correctly configured in your system's PATH.
* **For Personal Use Only:** This tool is intended for personal, private use, such as backing up music and videos you have legitimate access to via your Tidal subscription.
* **Respect Copyright:** Always respect copyright laws and Tidal's Terms of Service. Downloading and distributing copyrighted material without authorization may be illegal. The developers of this tool are not responsible for its misuse.
* **API Rate Limiting:** While the script includes some retry mechanisms, excessive or rapid use might lead to temporary rate limiting by Tidal's API. Use the tool reasonably.
## 📄 License
Copyright (c) 2025 AndresDevvv
This project is licensed under the terms of the **GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.**
A copy of the AGPL-3.0 license is included in the file `LICENSE` in the root of this repository. You can also find the full text of the license online at:
[https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html)
---
Happy Downloading!

385
startup.mjs Normal file
View File

@@ -0,0 +1,385 @@
'use strict';
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;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
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);
if (match && match[1]) {
return { type: expectedType, id: match[1] };
}
return null;
}
async function fetchHtmlContent(url) {
const urlObj = new URL(url);
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'en-US,en;q=0.9',
'Host': urlObj.hostname,
'Upgrade-Insecure-Requests': '1',
'Sec-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',
'Sec-Fetch-User': '?1',
'Connection': 'keep-alive',
};
try {
const response = await axios.get(url, { headers, timeout: 15000 });
return response.data;
} catch (error) {
console.error(`[fetchHtmlContent] Axios error fetching ${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)}`);
} else if (error.request) {
console.error('[fetchHtmlContent] No response received for URL:', url);
}
throw error;
}
}
function parseOgMeta(htmlContent, property) {
const regex = new RegExp(`<meta[^>]*property="og:${property}"[^>]*content="([^"]+)"`, 'i');
const match = htmlContent.match(regex);
return match ? match[1] : null;
}
async function fetchSongMetadataForRenaming(trackUrl) {
try {
let browseTrackUrl = trackUrl;
const urlObjInput = new URL(trackUrl);
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 (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;
}
console.log(`[fetchSongMetadataForRenaming] Fetching HTML from (normalized): ${browseTrackUrl}`);
const htmlContent = await fetchHtmlContent(browseTrackUrl);
let title = null;
let artist = null;
const pageTitleTagMatch = htmlContent.match(/<title>(.+?) by (.+?) on TIDAL<\/title>/i);
if (pageTitleTagMatch && pageTitleTagMatch[1] && pageTitleTagMatch[2]) {
title = pageTitleTagMatch[1].trim();
artist = pageTitleTagMatch[2].trim();
console.log(`[fetchSongMetadataForRenaming] From <title> tag - Title: "${title}", Artist: "${artist}"`);
} else {
console.log(`[fetchSongMetadataForRenaming] Could not parse <title> tag. Trying og:title.`);
const ogTitle = parseOgMeta(htmlContent, 'title');
if (ogTitle) {
const parts = ogTitle.split(' - ');
if (parts.length >= 2) {
title = parts[0].trim();
artist = parts.slice(1).join(' - ').trim();
console.log(`[fetchSongMetadataForRenaming] From og:title (assuming "Title - Artist") - 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(`[fetchSongMetadataForRenaming] Final extracted - Title: "${title}", Artist: "${artist}"`);
return { title, artist };
} catch (error) {
console.error(`[fetchSongMetadataForRenaming] Error fetching metadata from URL ${trackUrl}: ${error.message}`);
return { title: null, artist: null };
}
}
function sanitizeFilename(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);
}
async function handleSongRenaming(originalSongFilePath, songUrl) {
console.log("Fetching metadata for potential renaming...");
const metadata = await fetchSongMetadataForRenaming(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.");
}
return finalFilePath;
}
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" }
];
async function selectAudioQuality() {
console.log("\nAvailable Audio Qualities:");
AUDIO_QUALITIES.forEach((quality, index) => {
console.log(` ${index + 1}. ${quality.name} (API Code: ${quality.apiCode})`);
});
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;
} else {
console.log("Invalid selection. Please enter a number from the list.");
}
}
return AUDIO_QUALITIES[choiceIndex];
}
async function selectVideoQuality(videoId, accessToken) {
console.log("\nFetching available video qualities...");
let streams;
try {
streams = await fetchAvailableVideoStreams(videoId, accessToken);
} 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;
}
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}`);
});
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.");
}
}
return streams[choiceIndex];
}
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.");
let session;
try {
console.log("\nAttempting to authenticate with Tidal...");
session = await authenticate();
} catch (error) {
console.error("\nFatal error during the authentication process:", 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.");
rl.close();
return;
}
console.log("\n✅ Successfully authenticated with Tidal!");
console.log(` User ID: ${session.userId}, Country: ${session.countryCode}`);
const outputBaseDir = './downloads';
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");
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 === '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);
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}`);
continue;
}
const itemId = idInfo.id;
console.log(`\n🆔 Extracted ${idInfo.type} ID: ${itemId}`);
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.`);
}
} catch (error) {
console.error(`\n❌ An error occurred during the download of ${downloadType} ID ${itemId}.`);
console.error(` Specific error: ${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;
}
}
rl.close();
}
main().catch(error => {
console.error("\n🚨 An unexpected critical error occurred in the startup script:", error.message);
console.error(error.stack);
if (rl && typeof rl.close === 'function') rl.close();
process.exit(1);
});

282
v2/login.mjs Normal file
View File

@@ -0,0 +1,282 @@
import { promises as fs } from 'fs';
const API_CLIENT = {
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';
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;
}
isAccessTokenValid() {
return this.accessToken && this.tokenExpiresAt && Date.now() < this.tokenExpiresAt;
}
hasRefreshToken() {
return !!this.refreshToken;
}
updateTokens(tokenResponse) {
this.accessToken = tokenResponse.access_token;
if (tokenResponse.refresh_token) {
this.refreshToken = tokenResponse.refresh_token;
}
this.tokenExpiresAt = Date.now() + (tokenResponse.expires_in * 1000);
if (tokenResponse.user) {
this.userId = tokenResponse.user.userId;
this.countryCode = tokenResponse.user.countryCode;
}
}
clearAuthDetails() {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiresAt = null;
this.userId = null;
this.countryCode = null;
}
clearDeviceAuthDetails() {
this.deviceCode = null;
this.userCode = null;
this.verificationUrl = null;
this.authCheckTimeout = null;
this.authCheckInterval = null;
}
}
async function saveSession(session) {
const dataToSave = {
userId: session.userId,
countryCode: session.countryCode,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
tokenExpiresAt: session.tokenExpiresAt,
};
try {
await fs.writeFile(SESSION_STORAGE_FILE, JSON.stringify(dataToSave, null, 2));
console.log(`Session saved to ${SESSION_STORAGE_FILE}`);
} catch (error) {
console.error(`Error saving session to ${SESSION_STORAGE_FILE}:`, error.message);
}
}
async function loadSession() {
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);
} catch (error) {
if (error.code !== 'ENOENT') {
console.warn(`Could not load session from ${SESSION_STORAGE_FILE}: ${error.message}. A new session will be created.`);
} else {
console.log(`No session file found at ${SESSION_STORAGE_FILE}. A new session will be created.`);
}
return new TidalSession();
}
}
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
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));
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)));
}
}
throw new Error(`Failed to fetch ${url} after ${maxRetries} retries`);
}
async function getDeviceCode(session) {
console.log("Requesting new device code...");
const body = new URLSearchParams({
client_id: API_CLIENT.clientId,
scope: API_CLIENT.scope
});
const response = await fetchWithRetry(`${AUTH_URL_BASE}/device_authorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.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 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;
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)...");
}
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,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
scope: API_CLIENT.scope
});
while (Date.now() < session.authCheckTimeout) {
await new Promise(resolve => setTimeout(resolve, session.authCheckInterval));
process.stdout.write(".");
const response = await fetchWithRetry(`${AUTH_URL_BASE}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': basicAuth
},
body: body.toString()
});
if (response.ok) {
const tokenData = await response.json();
session.updateTokens(tokenData);
console.log("\nAuthorization successful!");
await saveSession(session);
session.clearDeviceAuthDetails();
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;
}
}
}
console.log("\nDevice-code authorization timed out.");
session.clearDeviceAuthDetails();
return false;
}
async function refreshAccessToken(session) {
if (!session.hasRefreshToken()) {
console.log("No refresh token available to 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,
grant_type: 'refresh_token',
scope: API_CLIENT.scope
});
const response = await fetchWithRetry(`${AUTH_URL_BASE}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': basicAuth
},
body: body.toString()
});
if (response.ok) {
const tokenData = await response.json();
session.updateTokens(tokenData);
console.log("Access token refreshed successfully.");
await saveSession(session);
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;
}
}
async function authenticate() {
let session = await loadSession();
if (session.isAccessTokenValid()) {
console.log("Found valid access token in session. Authentication successful (using existing session).");
return session;
}
if (session.hasRefreshToken()) {
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;
}
}
console.log("No valid session found or refresh failed. Starting new device authentication flow.");
session.clearAuthDetails();
try {
await getDeviceCode(session);
if (await pollForToken(session)) {
console.log("Authentication successful (new device authorization).");
return session;
} else {
console.error("Device authentication process failed or timed out.");
return null;
}
} catch (error) {
console.error(`Device authentication flow error: ${error.message}`);
return null;
}
}
export { authenticate, TidalSession, saveSession, loadSession, refreshAccessToken, getDeviceCode, pollForToken, API_CLIENT };

159
v2/music.js Normal file
View File

@@ -0,0 +1,159 @@
'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 };

279
v2/video.js Normal file
View File

@@ -0,0 +1,279 @@
'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 };