From 95ec9d8cf5a7ee62fbf82b296e267b35ba99a81c Mon Sep 17 00:00:00 2001 From: AndresDev Date: Sun, 11 May 2025 06:47:16 -0400 Subject: [PATCH] first commit --- .gitignore | 3 + package-lock.json | 323 ++++++++++++++++++++++++++++++++++++++ package.json | 16 ++ readme.md | 145 +++++++++++++++++ startup.mjs | 385 ++++++++++++++++++++++++++++++++++++++++++++++ v2/login.mjs | 282 +++++++++++++++++++++++++++++++++ v2/music.js | 159 +++++++++++++++++++ v2/video.js | 279 +++++++++++++++++++++++++++++++++ 8 files changed, 1592 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 readme.md create mode 100644 startup.mjs create mode 100644 v2/login.mjs create mode 100644 v2/music.js create mode 100644 v2/video.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8b8988 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/downloads/ +tidal_session.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1150f82 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b17f402 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..9708c43 --- /dev/null +++ b/readme.md @@ -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! \ No newline at end of file diff --git a/startup.mjs b/startup.mjs new file mode 100644 index 0000000..cbd11cf --- /dev/null +++ b/startup.mjs @@ -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(`]*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(/(.+?) 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); +}); \ No newline at end of file diff --git a/v2/login.mjs b/v2/login.mjs new file mode 100644 index 0000000..5160544 --- /dev/null +++ b/v2/login.mjs @@ -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 }; \ No newline at end of file diff --git a/v2/music.js b/v2/music.js new file mode 100644 index 0000000..6c032fc --- /dev/null +++ b/v2/music.js @@ -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 }; \ No newline at end of file diff --git a/v2/video.js b/v2/video.js new file mode 100644 index 0000000..1230432 --- /dev/null +++ b/v2/video.js @@ -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 }; \ No newline at end of file