mirror of
https://github.com/AndresDevvv/Tidal-DL.git
synced 2026-01-15 08:22:56 -03:00
first commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/node_modules/
|
||||
/downloads/
|
||||
tidal_session.json
|
||||
323
package-lock.json
generated
Normal file
323
package-lock.json
generated
Normal 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
16
package.json
Normal 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
145
readme.md
Normal 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
385
startup.mjs
Normal 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
282
v2/login.mjs
Normal 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
159
v2/music.js
Normal 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
279
v2/video.js
Normal 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 };
|
||||
Reference in New Issue
Block a user