Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bddeab0d1 | |||
| 03a30ee09a | |||
| 2d908e2f75 | |||
| e8f7bf7313 | |||
| 1f0922f358 | |||
| 3f267a3fa1 | |||
| 22da74a027 | |||
| 783350fe88 | |||
| 0057d43f46 | |||
| 9928968ffb | |||
| af4f1dd401 | |||
| 3414fadbd3 | |||
| 457f30da99 | |||
| d4e621b36c | |||
| 58a733b790 | |||
| c85ab4bc28 | |||
| dac2e99b5a | |||
| d0f494f582 | |||
| 0542d6e86b | |||
| de798e4807 | |||
| 0e7ba6d029 | |||
| 2306b1f8d2 | |||
| 1b0d67702d | |||
| 00e369677f | |||
| c3e1607ca6 | |||
| 59428e7679 | |||
| 33c4698286 | |||
| 3ac4c34d73 | |||
| 88e303cbe4 | |||
| c13855fadd | |||
| 2b12684960 | |||
| 4bc164cc56 | |||
| 46cb65665e | |||
| 276b3b4951 | |||
| e15aadbd61 | |||
| d7639bae8f | |||
| 1af7ab65c9 | |||
| c5240596cb | |||
| c4a9042adc | |||
| 45ac08ecbd | |||
| 0add305d9c | |||
| 9b6b43c0a4 | |||
| 60d20cbebe | |||
| 626d58667e | |||
| 4dd1a7ea12 | |||
| 67964e4acb | |||
| 1486fb13df | |||
| 966536f127 | |||
| 21946321f5 | |||
| 3e3cb0610d | |||
| 160eba0987 | |||
| 71a60ded47 | |||
| e0a0514df9 | |||
| 1e7a48d263 | |||
| 0a83a0dd6e | |||
| da429d9410 | |||
| 63211c726b | |||
| 055cb6991a | |||
| 222d681551 | |||
| 479c6ede2b | |||
| ceb727adb9 | |||
| bbea8ca493 | |||
| f567dd19bf |
@@ -1,99 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import zendriver as zd
|
|
||||||
|
|
||||||
async def get_metadata(page, headless=True):
|
|
||||||
max_attempts = 40
|
|
||||||
attempts = 0
|
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
await page.evaluate("""
|
|
||||||
window.downloadInfo = null;
|
|
||||||
const originalFetch = window.fetch;
|
|
||||||
window.fetch = async function(...args) {
|
|
||||||
const [url, config] = args;
|
|
||||||
if (url.includes('/api/load?url=%2Fapi%2Ffetch%2Fstream%2Fv2')) {
|
|
||||||
const payload = JSON.parse(config.body);
|
|
||||||
const title = document.querySelector('h1.svelte-6pt9ji').textContent.trim();
|
|
||||||
const artists = Array.from(document.querySelectorAll('h2.svelte-6pt9ji a.normal'))
|
|
||||||
.map(a => a.textContent.trim())
|
|
||||||
.join(', ');
|
|
||||||
const cover = document.querySelector('.svelte-6pt9ji .meta.svelte-6pt9ji a').href;
|
|
||||||
|
|
||||||
window.downloadInfo = {
|
|
||||||
url: payload.url,
|
|
||||||
cover: cover,
|
|
||||||
title: title,
|
|
||||||
artists: artists,
|
|
||||||
token: payload.token.primary,
|
|
||||||
expiry: payload.token.expiry
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return originalFetch.apply(this, args);
|
|
||||||
};
|
|
||||||
""")
|
|
||||||
|
|
||||||
await page.evaluate("""
|
|
||||||
function waitForElement(selector) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
if (document.querySelector(selector)) {
|
|
||||||
return resolve(document.querySelector(selector));
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new MutationObserver(mutations => {
|
|
||||||
if (document.querySelector(selector)) {
|
|
||||||
observer.disconnect();
|
|
||||||
resolve(document.querySelector(selector));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (!window.location.hostname.includes('lucida.')) return;
|
|
||||||
|
|
||||||
await Promise.race([
|
|
||||||
waitForElement('.d1-track button'),
|
|
||||||
waitForElement('button[class*="download-button"]')
|
|
||||||
]);
|
|
||||||
|
|
||||||
const clickDownloadButton = () => {
|
|
||||||
const button = document.querySelector('.d1-track button') ||
|
|
||||||
document.querySelector('button[class*="download-button"]');
|
|
||||||
if (button) button.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
clickDownloadButton();
|
|
||||||
})();
|
|
||||||
""")
|
|
||||||
|
|
||||||
while attempts < max_attempts:
|
|
||||||
download_info = await page.evaluate("window.downloadInfo")
|
|
||||||
if download_info:
|
|
||||||
return download_info
|
|
||||||
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
attempts += 1
|
|
||||||
|
|
||||||
raise TimeoutError("Timeout")
|
|
||||||
|
|
||||||
async def main(headless=True):
|
|
||||||
browser = await zd.start(headless=headless)
|
|
||||||
try:
|
|
||||||
track_id = "2plbrEY59IikOBgBGLjaoe"
|
|
||||||
url = f"https://lucida.to/?url=https%3A%2F%2Fopen.spotify.com%2Ftrack%2F{track_id}&country=auto&to=tidal"
|
|
||||||
|
|
||||||
page = await browser.get(url)
|
|
||||||
download_info = await get_metadata(page)
|
|
||||||
print(download_info)
|
|
||||||
return download_info
|
|
||||||
finally:
|
|
||||||
await browser.stop()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,29 +1,28 @@
|
|||||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal, Amazon Music and Deezer with the help of Lucida.
|
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal & Deezer.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v2.2/SpotiFLAC.exe)
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.0/SpotiFLAC.exe)
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
> [!WARNING]
|
> [!Important]
|
||||||
Sometimes, the **download speed** from Lucida can be fast or slow; it varies unpredictably.
|
> - Requires **Google Chrome, Chromium, Microsoft Edge,** or **Brave** to use `Deezer`
|
||||||
|
> - If after **Cloudflare** verification nothing happens, use a `VPN`, your country is likely blocked by `corsproxy.io`
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> When **Fallback** is enabled, it will use the backup server `Lucida.su`
|
|
||||||
|
|
||||||
## Lossless Audio Check
|
## Lossless Audio Check
|
||||||
|
|
||||||
|
|||||||
+593
-147
File diff suppressed because it is too large
Load Diff
+232
@@ -0,0 +1,232 @@
|
|||||||
|
import requests
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
|
||||||
|
class DeezerDownloader:
|
||||||
|
def __init__(self):
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
})
|
||||||
|
self.progress_callback = None
|
||||||
|
|
||||||
|
def set_progress_callback(self, callback):
|
||||||
|
self.progress_callback = callback
|
||||||
|
|
||||||
|
def get_track_by_isrc(self, isrc):
|
||||||
|
try:
|
||||||
|
url = f"https://api.deezer.com/2.0/track/isrc:{isrc}"
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if 'error' in data:
|
||||||
|
print(f"Error from Deezer API: {data['error']['message']}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Error fetching track data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_metadata(self, track_data):
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
metadata['title'] = track_data.get('title', '')
|
||||||
|
metadata['title_short'] = track_data.get('title_short', '')
|
||||||
|
metadata['duration'] = track_data.get('duration', 0)
|
||||||
|
metadata['track_position'] = track_data.get('track_position', 1)
|
||||||
|
metadata['disk_number'] = track_data.get('disk_number', 1)
|
||||||
|
metadata['isrc'] = track_data.get('isrc', '')
|
||||||
|
metadata['release_date'] = track_data.get('release_date', '')
|
||||||
|
metadata['explicit_lyrics'] = track_data.get('explicit_lyrics', False)
|
||||||
|
|
||||||
|
if 'artist' in track_data:
|
||||||
|
metadata['artist'] = track_data['artist'].get('name', '')
|
||||||
|
metadata['artist_id'] = track_data['artist'].get('id', '')
|
||||||
|
|
||||||
|
if 'contributors' in track_data:
|
||||||
|
artists = []
|
||||||
|
for contributor in track_data['contributors']:
|
||||||
|
if contributor.get('role') == 'Main':
|
||||||
|
artists.append(contributor.get('name', ''))
|
||||||
|
metadata['artists'] = ', '.join(artists) if artists else metadata.get('artist', '')
|
||||||
|
|
||||||
|
if 'album' in track_data:
|
||||||
|
album = track_data['album']
|
||||||
|
metadata['album'] = album.get('title', '')
|
||||||
|
metadata['album_id'] = album.get('id', '')
|
||||||
|
metadata['cover_url'] = album.get('cover_xl', album.get('cover_big', ''))
|
||||||
|
metadata['cover_md5'] = album.get('md5_image', '')
|
||||||
|
|
||||||
|
metadata['deezer_link'] = track_data.get('link', '')
|
||||||
|
metadata['preview_url'] = track_data.get('preview', '')
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def download_cover_art(self, cover_url, filename):
|
||||||
|
if not cover_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(cover_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
cover_path = f"{filename}_cover.jpg"
|
||||||
|
with open(cover_path, 'wb') as f:
|
||||||
|
f.write(response.content)
|
||||||
|
|
||||||
|
return cover_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading cover art: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def embed_metadata(self, file_path, metadata, cover_path=None):
|
||||||
|
try:
|
||||||
|
audio = FLAC(file_path)
|
||||||
|
|
||||||
|
audio.clear()
|
||||||
|
|
||||||
|
if metadata.get('title'):
|
||||||
|
audio['TITLE'] = metadata['title']
|
||||||
|
if metadata.get('artists'):
|
||||||
|
audio['ARTIST'] = metadata['artists']
|
||||||
|
elif metadata.get('artist'):
|
||||||
|
audio['ARTIST'] = metadata['artist']
|
||||||
|
if metadata.get('album'):
|
||||||
|
audio['ALBUM'] = metadata['album']
|
||||||
|
if metadata.get('release_date'):
|
||||||
|
audio['DATE'] = metadata['release_date']
|
||||||
|
if metadata.get('track_position'):
|
||||||
|
audio['TRACKNUMBER'] = str(metadata['track_position'])
|
||||||
|
if metadata.get('disk_number'):
|
||||||
|
audio['DISCNUMBER'] = str(metadata['disk_number'])
|
||||||
|
if metadata.get('isrc'):
|
||||||
|
audio['ISRC'] = metadata['isrc']
|
||||||
|
|
||||||
|
if cover_path and os.path.exists(cover_path):
|
||||||
|
with open(cover_path, 'rb') as f:
|
||||||
|
cover_data = f.read()
|
||||||
|
|
||||||
|
from mutagen.flac import Picture
|
||||||
|
picture = Picture()
|
||||||
|
picture.type = 3
|
||||||
|
picture.mime = 'image/jpeg'
|
||||||
|
picture.desc = 'Cover'
|
||||||
|
picture.data = cover_data
|
||||||
|
audio.add_picture(picture)
|
||||||
|
|
||||||
|
audio.save()
|
||||||
|
print(f"Metadata embedded successfully in {file_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error embedding metadata: {e}")
|
||||||
|
|
||||||
|
async def download_by_isrc(self, isrc, output_dir="."):
|
||||||
|
print(f"Fetching track info for ISRC: {isrc}")
|
||||||
|
|
||||||
|
track_data = self.get_track_by_isrc(isrc)
|
||||||
|
if not track_data:
|
||||||
|
print("Failed to get track data from Deezer API")
|
||||||
|
return False
|
||||||
|
|
||||||
|
metadata = self.extract_metadata(track_data)
|
||||||
|
print(f"Found track: {metadata.get('artists', 'Unknown')} - {metadata.get('title', 'Unknown')}")
|
||||||
|
|
||||||
|
track_id = track_data.get('id')
|
||||||
|
if not track_id:
|
||||||
|
print("No track ID found in Deezer API response")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Using track ID: {track_id}")
|
||||||
|
|
||||||
|
api_url = f"https://api.deezmate.com/dl/{track_id}"
|
||||||
|
print(f"Requesting download links from: {api_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(api_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
api_data = response.json()
|
||||||
|
|
||||||
|
if not api_data.get('success'):
|
||||||
|
print("API request failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
links = api_data.get('links', {})
|
||||||
|
flac_url = links.get('flac')
|
||||||
|
|
||||||
|
if not flac_url:
|
||||||
|
print("No FLAC download link found in API response")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Successfully obtained FLAC download URL")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting download URL from API: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("Downloading FLAC file...")
|
||||||
|
try:
|
||||||
|
response = self.session.get(flac_url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
|
print(f"File size: {total_size} bytes ({total_size / (1024*1024):.2f} MB)")
|
||||||
|
|
||||||
|
safe_title = "".join(c for c in metadata.get('title', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||||
|
safe_artist = "".join(c for c in metadata.get('artists', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||||
|
filename = f"{safe_artist} - {safe_title}.flac"
|
||||||
|
file_path = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if self.progress_callback and total_size > 0:
|
||||||
|
current_mb = downloaded / (1024 * 1024)
|
||||||
|
total_mb = total_size / (1024 * 1024)
|
||||||
|
percent = (downloaded / total_size) * 100
|
||||||
|
self.progress_callback(downloaded, total_size)
|
||||||
|
|
||||||
|
print(f"Downloaded: {file_path}")
|
||||||
|
|
||||||
|
cover_path = None
|
||||||
|
if metadata.get('cover_url'):
|
||||||
|
print("Downloading cover art...")
|
||||||
|
cover_path = self.download_cover_art(metadata['cover_url'],
|
||||||
|
os.path.join(output_dir, f"{safe_artist} - {safe_title}"))
|
||||||
|
|
||||||
|
print("Embedding metadata...")
|
||||||
|
self.embed_metadata(file_path, metadata, cover_path)
|
||||||
|
|
||||||
|
if cover_path and os.path.exists(cover_path):
|
||||||
|
os.remove(cover_path)
|
||||||
|
|
||||||
|
print(f"Successfully downloaded and tagged: {filename}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading file: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python deezerDL.py <ISRC>")
|
||||||
|
print("Example: python deezerDL.py USUM72409273")
|
||||||
|
return
|
||||||
|
|
||||||
|
isrc = sys.argv[1]
|
||||||
|
downloader = DeezerDownloader()
|
||||||
|
|
||||||
|
success = await downloader.download_by_isrc(isrc)
|
||||||
|
if success:
|
||||||
|
print("Download completed successfully!")
|
||||||
|
else:
|
||||||
|
print("Download failed!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
import nodriver as uc
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def download_deezer_track(deezer_link=None, initial_delay=7.5):
|
||||||
|
if deezer_link is None:
|
||||||
|
deezer_link = "https://www.deezer.com/us/track/2947516331"
|
||||||
|
|
||||||
|
browser = None
|
||||||
|
try:
|
||||||
|
browser = await uc.start(headless=False)
|
||||||
|
page = await browser.get("https://deezmate.com/en")
|
||||||
|
|
||||||
|
print("Loading...")
|
||||||
|
await asyncio.sleep(initial_delay)
|
||||||
|
|
||||||
|
input_selector = 'input[placeholder="Paste your Deezer link here..."]'
|
||||||
|
await page.wait_for(input_selector, timeout=15)
|
||||||
|
input_element = await page.select(input_selector)
|
||||||
|
await input_element.clear_input()
|
||||||
|
await input_element.send_keys(deezer_link)
|
||||||
|
print("Link entered")
|
||||||
|
|
||||||
|
await page.evaluate("""
|
||||||
|
window.apiResponse = null;
|
||||||
|
window.originalFetch = window.fetch;
|
||||||
|
window.fetch = function(...args) {
|
||||||
|
return window.originalFetch(...args).then(async response => {
|
||||||
|
if (response.url.includes('api.deezmate.com/dl/')) {
|
||||||
|
try {
|
||||||
|
const data = await response.clone().json();
|
||||||
|
window.apiResponse = data;
|
||||||
|
console.log('Captured API response:', data);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error parsing API response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
""")
|
||||||
|
|
||||||
|
max_retries = 3
|
||||||
|
download_button_clicked = False
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
download_button_selector = 'button.bg-purple.hover\\:bg-purple-dark.cursor-pointer.transition.text-white.rounded-xl.p-2.mt-2.w-full.mb-5'
|
||||||
|
await page.wait_for(download_button_selector, timeout=15)
|
||||||
|
download_button = await page.select(download_button_selector)
|
||||||
|
await download_button.click()
|
||||||
|
print("Processing...")
|
||||||
|
download_button_clicked = True
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
print(f"Turnstile verification failed, retrying... ({attempt + 1}/{max_retries})")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await page.evaluate("window.apiResponse = null;")
|
||||||
|
else:
|
||||||
|
print("Failed to pass Turnstile verification after all retries")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
if not download_button_clicked:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
track_download_selector = 'button.bg-purple.text-white.flex.items-center.gap-2.px-3.py-1.rounded-full.hover\\:bg-purple-dark.transition'
|
||||||
|
await page.wait_for(track_download_selector, timeout=15)
|
||||||
|
track_download_button = await page.select(track_download_selector)
|
||||||
|
await track_download_button.click()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to click track download button: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print("Getting FLAC URL from API response...")
|
||||||
|
|
||||||
|
api_response = None
|
||||||
|
for i in range(30):
|
||||||
|
api_response = await page.evaluate("window.apiResponse")
|
||||||
|
if api_response:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
if not api_response:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_nodriver_response(data):
|
||||||
|
if isinstance(data, list):
|
||||||
|
result = {}
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, list) and len(item) == 2:
|
||||||
|
key = item[0]
|
||||||
|
value_obj = item[1]
|
||||||
|
if isinstance(value_obj, dict) and 'value' in value_obj:
|
||||||
|
if value_obj.get('type') == 'object':
|
||||||
|
result[key] = parse_nodriver_response(value_obj['value'])
|
||||||
|
else:
|
||||||
|
result[key] = value_obj['value']
|
||||||
|
return result
|
||||||
|
return data
|
||||||
|
|
||||||
|
parsed_response = parse_nodriver_response(api_response)
|
||||||
|
|
||||||
|
if parsed_response.get('success') and parsed_response.get('links'):
|
||||||
|
flac_url = parsed_response['links'].get('flac')
|
||||||
|
if flac_url:
|
||||||
|
print(f"Successfully obtained FLAC download URL: {flac_url}")
|
||||||
|
return flac_url
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if browser:
|
||||||
|
try:
|
||||||
|
await browser.stop()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def main(deezer_link=None, initial_delay=7.5):
|
||||||
|
flac_url = await download_deezer_track(deezer_link, initial_delay)
|
||||||
|
if not flac_url:
|
||||||
|
print("Failed to download track")
|
||||||
|
return flac_url
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uc.loop().run_until_complete(main())
|
||||||
+247
-61
@@ -2,37 +2,58 @@ from time import sleep
|
|||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
import hmac
|
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import pyotp
|
||||||
from typing import Tuple, Callable
|
import base64
|
||||||
|
from random import randrange
|
||||||
|
from typing import Dict, Any, List, Tuple
|
||||||
|
|
||||||
_TOTP_SECRET = bytearray([53,53,48,55,49,52,53,56,53,51,52,56,55,52,57,57,53,57,50,50,52,56,54,51,48,51,50,57,51,52,55])
|
# https://github.com/visagenull/Spotify-Free
|
||||||
|
def get_random_user_agent():
|
||||||
|
return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}"
|
||||||
|
|
||||||
def generate_totp(
|
def generate_totp():
|
||||||
secret: bytes = _TOTP_SECRET,
|
url = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretBytes.json"
|
||||||
algorithm: Callable[[], object] = hashlib.sha1,
|
|
||||||
digits: int = 6,
|
|
||||||
counter_factory: Callable[[], int] = lambda: int(time.time()) // 30,
|
|
||||||
) -> Tuple[str, int]:
|
|
||||||
counter = counter_factory()
|
|
||||||
hmac_result = hmac.new(
|
|
||||||
secret, counter.to_bytes(8, byteorder="big"), algorithm
|
|
||||||
).digest()
|
|
||||||
|
|
||||||
offset = hmac_result[-1] & 15
|
try:
|
||||||
truncated_value = (
|
resp = requests.get(url, timeout=10)
|
||||||
(hmac_result[offset] & 127) << 24
|
if resp.status_code != 200:
|
||||||
| (hmac_result[offset + 1] & 255) << 16
|
raise Exception(f"Failed to fetch TOTP secrets from GitHub. Status: {resp.status_code}")
|
||||||
| (hmac_result[offset + 2] & 255) << 8
|
secrets_list = resp.json()
|
||||||
| (hmac_result[offset + 3] & 255)
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
str(truncated_value % (10**digits)).zfill(digits),
|
|
||||||
counter * 30_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
token_url = 'https://open.spotify.com/get_access_token'
|
latest_entry = max(secrets_list, key=lambda x: x["version"])
|
||||||
|
version = latest_entry["version"]
|
||||||
|
secret_cipher = latest_entry["secret"]
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Failed to fetch secrets from GitHub: {str(e)}")
|
||||||
|
|
||||||
|
processed = [byte ^ ((i % 33) + 9) for i, byte in enumerate(secret_cipher)]
|
||||||
|
processed_str = "".join(map(str, processed))
|
||||||
|
utf8_bytes = processed_str.encode('utf-8')
|
||||||
|
hex_str = utf8_bytes.hex()
|
||||||
|
secret_bytes = bytes.fromhex(hex_str)
|
||||||
|
b32_secret = base64.b32encode(secret_bytes).decode('utf-8')
|
||||||
|
totp = pyotp.TOTP(b32_secret)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Host": "open.spotify.com",
|
||||||
|
"User-Agent": get_random_user_agent(),
|
||||||
|
"Accept": "*/*",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get("https://open.spotify.com/api/server-time", headers=headers, timeout=10)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f"Failed to get server time. Status code: {resp.status_code}")
|
||||||
|
data = resp.json()
|
||||||
|
server_time = data.get("serverTime")
|
||||||
|
if server_time is None:
|
||||||
|
raise Exception("Failed to fetch server time from Spotify")
|
||||||
|
return totp, server_time, version
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error getting server time: {str(e)}")
|
||||||
|
|
||||||
|
token_url = 'https://open.spotify.com/api/token'
|
||||||
playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
|
playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
|
||||||
album_base_url = 'https://api.spotify.com/v1/albums/{}'
|
album_base_url = 'https://api.spotify.com/v1/albums/{}'
|
||||||
track_base_url = 'https://api.spotify.com/v1/tracks/{}'
|
track_base_url = 'https://api.spotify.com/v1/tracks/{}'
|
||||||
@@ -100,51 +121,137 @@ def get_json_from_api(api_url, access_token):
|
|||||||
|
|
||||||
return req.json()
|
return req.json()
|
||||||
|
|
||||||
def get_raw_spotify_data(spotify_url):
|
def get_access_token():
|
||||||
url_info = parse_uri(spotify_url)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
totp, timestamp = generate_totp()
|
totp, server_time, totp_version = generate_totp()
|
||||||
|
otp_code = totp.at(int(server_time))
|
||||||
|
timestamp_ms = int(time.time() * 1000)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"reason": "init",
|
'reason': 'init',
|
||||||
"productType": "web-player",
|
'productType': 'web-player',
|
||||||
"totp": totp,
|
'totp': otp_code,
|
||||||
"totpVer": 5,
|
'totpServerTime': server_time,
|
||||||
"ts": timestamp,
|
'totpVer': str(totp_version),
|
||||||
|
'sTime': server_time,
|
||||||
|
'cTime': timestamp_ms,
|
||||||
|
'buildVer': 'web-player_2025-07-02_1720000000000_12345678',
|
||||||
|
'buildDate': '2025-07-02'
|
||||||
}
|
}
|
||||||
|
|
||||||
req = requests.get(token_url, headers=headers, params=params, timeout=10)
|
req = requests.get(token_url, headers=headers, params=params, timeout=10)
|
||||||
if req.status_code != 200:
|
if req.status_code != 200:
|
||||||
return {"error": f"Failed to get access token. Status code: {req.status_code}"}
|
return {"error": f"Failed to get access token. Status code: {req.status_code}"}
|
||||||
token = req.json()
|
return req.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Failed to get access token: {str(e)}"}
|
return {"error": f"Failed to get access token: {str(e)}"}
|
||||||
|
|
||||||
|
def fetch_tracks_in_batches(url: str, access_token: str, batch_size: int = 100, delay: float = 1.0) -> Tuple[List[Dict[str, Any]], int]:
|
||||||
|
all_tracks = []
|
||||||
|
current_batch = 0
|
||||||
|
|
||||||
|
while url:
|
||||||
|
print(f"Batch : {current_batch}")
|
||||||
|
|
||||||
|
url_parts = url.split("offset=")
|
||||||
|
if len(url_parts) > 1:
|
||||||
|
offset_part = url_parts[1].split("&")[0]
|
||||||
|
print(f"Offset : {offset_part}")
|
||||||
|
print("-------------")
|
||||||
|
|
||||||
|
track_data = get_json_from_api(url, access_token)
|
||||||
|
if not track_data:
|
||||||
|
break
|
||||||
|
|
||||||
|
items = track_data.get('items', [])
|
||||||
|
all_tracks.extend(items)
|
||||||
|
|
||||||
|
url = track_data.get('next')
|
||||||
|
if url and "&locale=" in url:
|
||||||
|
url = url.split("&locale=")[0]
|
||||||
|
|
||||||
|
if url and delay > 0:
|
||||||
|
sleep(delay)
|
||||||
|
|
||||||
|
current_batch += 1
|
||||||
|
|
||||||
|
return all_tracks, current_batch
|
||||||
|
|
||||||
|
def get_raw_spotify_data(spotify_url, batch: bool = False, delay: float = 1.0):
|
||||||
|
url_info = parse_uri(spotify_url)
|
||||||
|
token = get_access_token()
|
||||||
|
|
||||||
|
if "error" in token:
|
||||||
|
return token
|
||||||
|
|
||||||
|
access_token = token["accessToken"]
|
||||||
raw_data = {}
|
raw_data = {}
|
||||||
|
|
||||||
if url_info['type'] == "playlist":
|
if url_info['type'] == "playlist":
|
||||||
try:
|
try:
|
||||||
playlist_data = get_json_from_api(
|
playlist_data = get_json_from_api(
|
||||||
playlist_base_url.format(url_info["id"]),
|
playlist_base_url.format(url_info["id"]),
|
||||||
token["accessToken"]
|
access_token
|
||||||
)
|
)
|
||||||
if not playlist_data:
|
if not playlist_data:
|
||||||
return {"error": "Failed to get playlist data"}
|
return {"error": "Failed to get playlist data"}
|
||||||
|
|
||||||
raw_data = playlist_data
|
raw_data = playlist_data
|
||||||
|
total_tracks = playlist_data.get('tracks', {}).get('total', 0)
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
|
||||||
|
tracks, num_batches = fetch_tracks_in_batches(tracks_url, access_token, 100, delay)
|
||||||
|
raw_data['tracks']['items'] = tracks
|
||||||
|
raw_data['_batch_count'] = num_batches
|
||||||
|
raw_data['_batch_enabled'] = True
|
||||||
|
|
||||||
|
if len(tracks) < total_tracks:
|
||||||
|
last_offset = len(tracks)
|
||||||
|
remaining_tracks = []
|
||||||
|
|
||||||
|
while last_offset < total_tracks:
|
||||||
|
print(f"Batch : {num_batches}")
|
||||||
|
print(f"Offset : {last_offset}")
|
||||||
|
print("-------------")
|
||||||
|
|
||||||
|
remainder_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?offset={last_offset}&limit=100'
|
||||||
|
track_data = get_json_from_api(remainder_url, access_token)
|
||||||
|
|
||||||
|
if not track_data or not track_data.get('items'):
|
||||||
|
break
|
||||||
|
|
||||||
|
items = track_data.get('items', [])
|
||||||
|
remaining_tracks.extend(items)
|
||||||
|
|
||||||
|
if len(items) < 100:
|
||||||
|
break
|
||||||
|
|
||||||
|
last_offset += len(items)
|
||||||
|
num_batches += 1
|
||||||
|
|
||||||
|
if delay > 0:
|
||||||
|
sleep(delay)
|
||||||
|
|
||||||
|
tracks.extend(remaining_tracks)
|
||||||
|
raw_data['tracks']['items'] = tracks
|
||||||
|
raw_data['_batch_count'] = num_batches
|
||||||
|
else:
|
||||||
tracks = []
|
tracks = []
|
||||||
tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
|
tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
|
||||||
while tracks_url:
|
while tracks_url:
|
||||||
track_data = get_json_from_api(tracks_url, token["accessToken"])
|
track_data = get_json_from_api(tracks_url, access_token)
|
||||||
if not track_data:
|
if not track_data:
|
||||||
break
|
break
|
||||||
|
|
||||||
tracks.extend(track_data['items'])
|
tracks.extend(track_data['items'])
|
||||||
tracks_url = track_data.get('next')
|
tracks_url = track_data.get('next')
|
||||||
|
if tracks_url and "&locale=" in tracks_url:
|
||||||
|
tracks_url = tracks_url.split("&locale=")[0]
|
||||||
|
|
||||||
raw_data['tracks']['items'] = tracks
|
raw_data['tracks']['items'] = tracks
|
||||||
|
raw_data['_batch_enabled'] = False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Failed to get playlist data: {str(e)}"}
|
return {"error": f"Failed to get playlist data: {str(e)}"}
|
||||||
|
|
||||||
@@ -152,25 +259,68 @@ def get_raw_spotify_data(spotify_url):
|
|||||||
try:
|
try:
|
||||||
album_data = get_json_from_api(
|
album_data = get_json_from_api(
|
||||||
album_base_url.format(url_info["id"]),
|
album_base_url.format(url_info["id"]),
|
||||||
token["accessToken"]
|
access_token
|
||||||
)
|
)
|
||||||
if not album_data:
|
if not album_data:
|
||||||
return {"error": "Failed to get album data"}
|
return {"error": "Failed to get album data"}
|
||||||
|
|
||||||
album_data['_token'] = token["accessToken"]
|
album_data['_token'] = access_token
|
||||||
raw_data = album_data
|
raw_data = album_data
|
||||||
|
total_tracks = album_data.get('total_tracks', 0)
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
|
||||||
|
tracks, num_batches = fetch_tracks_in_batches(tracks_url, access_token, 50, delay)
|
||||||
|
raw_data['tracks']['items'] = tracks
|
||||||
|
raw_data['_batch_count'] = num_batches
|
||||||
|
raw_data['_batch_enabled'] = True
|
||||||
|
|
||||||
|
if len(tracks) < total_tracks:
|
||||||
|
last_offset = len(tracks)
|
||||||
|
remaining_tracks = []
|
||||||
|
|
||||||
|
while last_offset < total_tracks:
|
||||||
|
print(f"Batch : {num_batches}")
|
||||||
|
print(f"Offset : {last_offset}")
|
||||||
|
print("-------------")
|
||||||
|
|
||||||
|
remainder_url = f'{album_base_url.format(url_info["id"])}/tracks?offset={last_offset}&limit=50'
|
||||||
|
track_data = get_json_from_api(remainder_url, access_token)
|
||||||
|
|
||||||
|
if not track_data or not track_data.get('items'):
|
||||||
|
break
|
||||||
|
|
||||||
|
items = track_data.get('items', [])
|
||||||
|
remaining_tracks.extend(items)
|
||||||
|
|
||||||
|
if len(items) < 50:
|
||||||
|
break
|
||||||
|
|
||||||
|
last_offset += len(items)
|
||||||
|
num_batches += 1
|
||||||
|
|
||||||
|
if delay > 0:
|
||||||
|
sleep(delay)
|
||||||
|
|
||||||
|
tracks.extend(remaining_tracks)
|
||||||
|
raw_data['tracks']['items'] = tracks
|
||||||
|
raw_data['_batch_count'] = num_batches
|
||||||
|
else:
|
||||||
tracks = []
|
tracks = []
|
||||||
tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
|
tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
|
||||||
while tracks_url:
|
while tracks_url:
|
||||||
track_data = get_json_from_api(tracks_url, token["accessToken"])
|
track_data = get_json_from_api(tracks_url, access_token)
|
||||||
if not track_data:
|
if not track_data:
|
||||||
break
|
break
|
||||||
|
|
||||||
tracks.extend(track_data['items'])
|
tracks.extend(track_data['items'])
|
||||||
tracks_url = track_data.get('next')
|
tracks_url = track_data.get('next')
|
||||||
|
if tracks_url and "&locale=" in tracks_url:
|
||||||
|
tracks_url = tracks_url.split("&locale=")[0]
|
||||||
|
|
||||||
raw_data['tracks']['items'] = tracks
|
raw_data['tracks']['items'] = tracks
|
||||||
|
raw_data['_batch_enabled'] = False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Failed to get album data: {str(e)}"}
|
return {"error": f"Failed to get album data: {str(e)}"}
|
||||||
|
|
||||||
@@ -178,7 +328,7 @@ def get_raw_spotify_data(spotify_url):
|
|||||||
try:
|
try:
|
||||||
track_data = get_json_from_api(
|
track_data = get_json_from_api(
|
||||||
track_base_url.format(url_info["id"]),
|
track_base_url.format(url_info["id"]),
|
||||||
token["accessToken"]
|
access_token
|
||||||
)
|
)
|
||||||
if not track_data:
|
if not track_data:
|
||||||
return {"error": "Failed to get track data"}
|
return {"error": "Failed to get track data"}
|
||||||
@@ -191,10 +341,12 @@ def get_raw_spotify_data(spotify_url):
|
|||||||
|
|
||||||
def format_track_data(track_data):
|
def format_track_data(track_data):
|
||||||
artists = []
|
artists = []
|
||||||
for artist in track_data['artists']:
|
for artist in track_data.get('artists', []):
|
||||||
artists.append(artist['name'])
|
artists.append(artist['name'])
|
||||||
|
|
||||||
image_url = track_data.get('album', {}).get('images', [{}])[0].get('url', '')
|
image_url = track_data.get('album', {}).get('images', [{}])[0].get('url', '') if track_data.get('album', {}).get('images') else ''
|
||||||
|
|
||||||
|
isrc = track_data.get('external_ids', {}).get('isrc', '')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"track": {
|
"track": {
|
||||||
@@ -205,16 +357,17 @@ def format_track_data(track_data):
|
|||||||
"images": image_url,
|
"images": image_url,
|
||||||
"release_date": track_data.get('album', {}).get('release_date', ''),
|
"release_date": track_data.get('album', {}).get('release_date', ''),
|
||||||
"track_number": track_data.get('track_number', 0),
|
"track_number": track_data.get('track_number', 0),
|
||||||
"external_urls": track_data.get('external_urls', {}).get('spotify', '')
|
"external_urls": track_data.get('external_urls', {}).get('spotify', ''),
|
||||||
|
"isrc": isrc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def format_album_data(album_data):
|
def format_album_data(album_data):
|
||||||
artists = []
|
artists = []
|
||||||
for artist in album_data['artists']:
|
for artist in album_data.get('artists', []):
|
||||||
artists.append(artist['name'])
|
artists.append(artist['name'])
|
||||||
|
|
||||||
image_url = album_data.get('images', [{}])[0].get('url', '')
|
image_url = album_data.get('images', [{}])[0].get('url', '') if album_data.get('images') else ''
|
||||||
|
|
||||||
track_list = []
|
track_list = []
|
||||||
for track in album_data.get('tracks', {}).get('items', []):
|
for track in album_data.get('tracks', {}).get('items', []):
|
||||||
@@ -222,6 +375,20 @@ def format_album_data(album_data):
|
|||||||
for artist in track.get('artists', []):
|
for artist in track.get('artists', []):
|
||||||
track_artists.append(artist['name'])
|
track_artists.append(artist['name'])
|
||||||
|
|
||||||
|
track_id = track.get('id', '')
|
||||||
|
track_isrc = ''
|
||||||
|
|
||||||
|
if track_id and album_data.get('_token'):
|
||||||
|
try:
|
||||||
|
full_track_data = get_json_from_api(
|
||||||
|
track_base_url.format(track_id),
|
||||||
|
album_data.get('_token')
|
||||||
|
)
|
||||||
|
if full_track_data:
|
||||||
|
track_isrc = full_track_data.get('external_ids', {}).get('isrc', '')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
track_list.append({
|
track_list.append({
|
||||||
"artists": ", ".join(track_artists),
|
"artists": ", ".join(track_artists),
|
||||||
"name": track.get('name', ''),
|
"name": track.get('name', ''),
|
||||||
@@ -230,32 +397,45 @@ def format_album_data(album_data):
|
|||||||
"images": image_url,
|
"images": image_url,
|
||||||
"release_date": album_data.get('release_date', ''),
|
"release_date": album_data.get('release_date', ''),
|
||||||
"track_number": track.get('track_number', 0),
|
"track_number": track.get('track_number', 0),
|
||||||
"external_urls": track.get('external_urls', {}).get('spotify', '')
|
"external_urls": track.get('external_urls', {}).get('spotify', ''),
|
||||||
|
"isrc": track_isrc
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
album_info = {
|
||||||
"album_info": {
|
|
||||||
"total_tracks": album_data.get('total_tracks', 0),
|
"total_tracks": album_data.get('total_tracks', 0),
|
||||||
"name": album_data.get('name', ''),
|
"name": album_data.get('name', ''),
|
||||||
"release_date": album_data.get('release_date', ''),
|
"release_date": album_data.get('release_date', ''),
|
||||||
"artists": ", ".join(artists),
|
"artists": ", ".join(artists),
|
||||||
"images": image_url
|
"images": image_url
|
||||||
},
|
}
|
||||||
|
|
||||||
|
if album_data.get('_batch_enabled', False):
|
||||||
|
album_info["batch"] = f"{album_data.get('_batch_count', 1)}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"album_info": album_info,
|
||||||
"track_list": track_list
|
"track_list": track_list
|
||||||
}
|
}
|
||||||
|
|
||||||
def format_playlist_data(playlist_data):
|
def format_playlist_data(playlist_data):
|
||||||
image_url = playlist_data.get('images', [{}])[0].get('url', '')
|
image_url = playlist_data.get('images', [{}])[0].get('url', '') if playlist_data.get('images') else ''
|
||||||
|
|
||||||
track_list = []
|
track_list = []
|
||||||
for item in playlist_data.get('tracks', {}).get('items', []):
|
for item in playlist_data.get('tracks', {}).get('items', []):
|
||||||
track = item.get('track', {})
|
track = item.get('track', {})
|
||||||
|
if not track:
|
||||||
|
continue
|
||||||
|
|
||||||
artists = []
|
artists = []
|
||||||
for artist in track.get('artists', []):
|
for artist in track.get('artists', []):
|
||||||
artists.append(artist['name'])
|
artists.append(artist['name'])
|
||||||
|
|
||||||
|
track_image = ''
|
||||||
|
if track.get('album', {}).get('images'):
|
||||||
track_image = track.get('album', {}).get('images', [{}])[0].get('url', '')
|
track_image = track.get('album', {}).get('images', [{}])[0].get('url', '')
|
||||||
|
|
||||||
|
track_isrc = track.get('external_ids', {}).get('isrc', '')
|
||||||
|
|
||||||
track_list.append({
|
track_list.append({
|
||||||
"artists": ", ".join(artists),
|
"artists": ", ".join(artists),
|
||||||
"name": track.get('name', ''),
|
"name": track.get('name', ''),
|
||||||
@@ -264,11 +444,11 @@ def format_playlist_data(playlist_data):
|
|||||||
"images": track_image,
|
"images": track_image,
|
||||||
"release_date": track.get('album', {}).get('release_date', ''),
|
"release_date": track.get('album', {}).get('release_date', ''),
|
||||||
"track_number": track.get('track_number', 0),
|
"track_number": track.get('track_number', 0),
|
||||||
"external_urls": track.get('external_urls', {}).get('spotify', '')
|
"external_urls": track.get('external_urls', {}).get('spotify', ''),
|
||||||
|
"isrc": track_isrc
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
playlist_info = {
|
||||||
"playlist_info": {
|
|
||||||
"tracks": {"total": playlist_data.get('tracks', {}).get('total', 0)},
|
"tracks": {"total": playlist_data.get('tracks', {}).get('total', 0)},
|
||||||
"followers": {"total": playlist_data.get('followers', {}).get('total', 0)},
|
"followers": {"total": playlist_data.get('followers', {}).get('total', 0)},
|
||||||
"owner": {
|
"owner": {
|
||||||
@@ -276,7 +456,13 @@ def format_playlist_data(playlist_data):
|
|||||||
"name": playlist_data.get('name', ''),
|
"name": playlist_data.get('name', ''),
|
||||||
"images": image_url
|
"images": image_url
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
if playlist_data.get('_batch_enabled', False):
|
||||||
|
playlist_info["batch"] = f"{playlist_data.get('_batch_count', 1)}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"playlist_info": playlist_info,
|
||||||
"track_list": track_list
|
"track_list": track_list
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,8 +482,8 @@ def process_spotify_data(raw_data, data_type):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Error processing data: {str(e)}"}
|
return {"error": f"Error processing data: {str(e)}"}
|
||||||
|
|
||||||
def get_filtered_data(spotify_url):
|
def get_filtered_data(spotify_url, batch=False, delay=1.0):
|
||||||
raw_data = get_raw_spotify_data(spotify_url)
|
raw_data = get_raw_spotify_data(spotify_url, batch=batch, delay=delay)
|
||||||
if raw_data and "error" not in raw_data:
|
if raw_data and "error" not in raw_data:
|
||||||
url_info = parse_uri(spotify_url)
|
url_info = parse_uri(spotify_url)
|
||||||
filtered_data = process_spotify_data(raw_data, url_info['type'])
|
filtered_data = process_spotify_data(raw_data, url_info['type'])
|
||||||
@@ -306,10 +492,10 @@ def get_filtered_data(spotify_url):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
playlist = "https://open.spotify.com/playlist/37i9dQZEVXbNG2KDcFcKOF"
|
playlist = "https://open.spotify.com/playlist/37i9dQZEVXbNG2KDcFcKOF"
|
||||||
album = "https://open.spotify.com/album/7kFyd5oyJdVX2pIi6P4iHE"
|
album = "https://open.spotify.com/album/6J84szYCnMfzEcvIcfWMFL"
|
||||||
song = "https://open.spotify.com/track/4wJ5Qq0jBN4ajy7ouZIV1c"
|
song = "https://open.spotify.com/track/7so0lgd0zP2Sbgs2d7a1SZ"
|
||||||
|
|
||||||
filtered_playlist = get_filtered_data(playlist)
|
filtered_playlist = get_filtered_data(playlist, batch=True, delay=0.1)
|
||||||
print(json.dumps(filtered_playlist, indent=2))
|
print(json.dumps(filtered_playlist, indent=2))
|
||||||
|
|
||||||
filtered_album = get_filtered_data(album)
|
filtered_album = get_filtered_data(album)
|
||||||
|
|||||||
-312
@@ -1,312 +0,0 @@
|
|||||||
import requests
|
|
||||||
import time
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
import re
|
|
||||||
import base64
|
|
||||||
|
|
||||||
class TrackDownloader:
|
|
||||||
def __init__(self, use_fallback=False):
|
|
||||||
self.client = requests.Session()
|
|
||||||
self.headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
|
||||||
}
|
|
||||||
self.progress_callback = None
|
|
||||||
self.filename_format = 'title_artist'
|
|
||||||
self.use_fallback = use_fallback
|
|
||||||
self.base_domain = "lucida.su" if use_fallback else "lucida.to"
|
|
||||||
|
|
||||||
def set_progress_callback(self, callback):
|
|
||||||
self.progress_callback = callback
|
|
||||||
|
|
||||||
def set_filename_format(self, format_type):
|
|
||||||
self.filename_format = format_type
|
|
||||||
|
|
||||||
def generate_filename(self, metadata):
|
|
||||||
if self.filename_format == 'artist_title':
|
|
||||||
filename = f"{metadata['artists']} - {metadata['title']}.flac"
|
|
||||||
else:
|
|
||||||
filename = f"{metadata['title']} - {metadata['artists']}.flac"
|
|
||||||
return self.sanitize_filename(filename)
|
|
||||||
|
|
||||||
async def get_track_info(self, track_id, service="amazon", use_fallback=None):
|
|
||||||
if use_fallback is None:
|
|
||||||
use_fallback = self.use_fallback
|
|
||||||
|
|
||||||
domain_type = "su" if use_fallback else "to"
|
|
||||||
|
|
||||||
spotify_url = f"https://open.spotify.com/track/{track_id}"
|
|
||||||
|
|
||||||
result = self.convert_spotify_link(spotify_url, service, domain_type)
|
|
||||||
|
|
||||||
if "error" in result:
|
|
||||||
raise Exception(f"Failed to get track info: {result['error']}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def convert_spotify_link(self, spotify_url, target_service="amazon", domain_type="to"):
|
|
||||||
track_id_match = re.search(r'track/([a-zA-Z0-9]+)', spotify_url)
|
|
||||||
if not track_id_match:
|
|
||||||
return {"error": "Invalid Spotify URL"}
|
|
||||||
|
|
||||||
domain = "lucida.to" if domain_type == "to" else "lucida.su"
|
|
||||||
base_url = f"https://{domain}"
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"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": "id-ID,id;q=0.9",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"Host": domain,
|
|
||||||
"Pragma": "no-cache",
|
|
||||||
"Upgrade-Insecure-Requests": "1",
|
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
headers["Referer"] = f"{base_url}/?url={spotify_url}&country=auto"
|
|
||||||
|
|
||||||
request_params = {
|
|
||||||
"url": spotify_url,
|
|
||||||
"country": "auto",
|
|
||||||
"to": target_service
|
|
||||||
}
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
session.verify = False
|
|
||||||
|
|
||||||
response = session.get(
|
|
||||||
base_url,
|
|
||||||
params=request_params,
|
|
||||||
headers=headers,
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
html_content = response.text
|
|
||||||
|
|
||||||
token_match = re.search(r'token:"([^"]+)"', html_content)
|
|
||||||
token_expiry_match = re.search(r'tokenExpiry:(\d+)', html_content)
|
|
||||||
|
|
||||||
token = token_match.group(1) if token_match else None
|
|
||||||
token_expiry = int(token_expiry_match.group(1)) if token_expiry_match else None
|
|
||||||
|
|
||||||
url = None
|
|
||||||
url_patterns = [
|
|
||||||
r'"url":"([^"]+)"',
|
|
||||||
r'href="(https?://[^"]*' + re.escape(target_service) + r'[^"]*track[^"]*)"',
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in url_patterns:
|
|
||||||
url_match = re.search(pattern, html_content)
|
|
||||||
if url_match:
|
|
||||||
url = url_match.group(1).replace('\\/', '/')
|
|
||||||
break
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
redirect_patterns = [
|
|
||||||
r'url=([^&"]+)',
|
|
||||||
r'href="([^"]+)"',
|
|
||||||
r'window\.location\.href\s*=\s*[\'"]([^\'"]+)[\'"]',
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in redirect_patterns:
|
|
||||||
matches = re.finditer(pattern, html_content)
|
|
||||||
for match in matches:
|
|
||||||
potential_url = match.group(1)
|
|
||||||
if potential_url.startswith('http') and target_service.lower() in potential_url.lower():
|
|
||||||
url = potential_url.replace('\\/', '/')
|
|
||||||
break
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
service_urls = re.finditer(r'(https?://[^"\s]+' + re.escape(target_service) + r'[^"\s]+)', html_content)
|
|
||||||
for match in service_urls:
|
|
||||||
url = match.group(1).replace('\\/', '/')
|
|
||||||
break
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"service": target_service,
|
|
||||||
"url": url,
|
|
||||||
"token": {
|
|
||||||
"primary": None,
|
|
||||||
"expiry": None
|
|
||||||
},
|
|
||||||
"title": "Title",
|
|
||||||
"artists": "Artist"
|
|
||||||
}
|
|
||||||
|
|
||||||
if token:
|
|
||||||
try:
|
|
||||||
decoded_once = base64.b64decode(token).decode('latin1')
|
|
||||||
decoded_token = base64.b64decode(decoded_once).decode('latin1')
|
|
||||||
result["token"]["primary"] = decoded_token
|
|
||||||
except Exception:
|
|
||||||
result["token"]["primary"] = token
|
|
||||||
|
|
||||||
result["token"]["expiry"] = token_expiry
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as error:
|
|
||||||
return {"error": str(error)}
|
|
||||||
|
|
||||||
def sanitize_filename(self, filename):
|
|
||||||
invalid_chars = '<>:"/\\|?*'
|
|
||||||
for char in invalid_chars:
|
|
||||||
filename = filename.replace(char, '')
|
|
||||||
|
|
||||||
filename = ' '.join(filename.split())
|
|
||||||
filename = filename.replace(' ,', ',')
|
|
||||||
filename = filename.replace(',', ', ')
|
|
||||||
while ' ' in filename:
|
|
||||||
filename = filename.replace(' ', ' ')
|
|
||||||
filename = filename.rsplit('.', 1)
|
|
||||||
filename[0] = filename[0].strip()
|
|
||||||
return '.'.join(filename)
|
|
||||||
|
|
||||||
def download(self, metadata, output_dir):
|
|
||||||
track_url = metadata['url']
|
|
||||||
primary_token = metadata['token']['primary']
|
|
||||||
expiry = metadata['token']['expiry']
|
|
||||||
|
|
||||||
print(f"Starting download for: {track_url}")
|
|
||||||
|
|
||||||
initial_request = {
|
|
||||||
"account": {"id": "auto", "type": "country"},
|
|
||||||
"compat": "false",
|
|
||||||
"downscale": "original",
|
|
||||||
"handoff": True,
|
|
||||||
"metadata": True,
|
|
||||||
"private": True,
|
|
||||||
"token": {
|
|
||||||
"expiry": expiry,
|
|
||||||
"primary": primary_token
|
|
||||||
},
|
|
||||||
"upload": {"enabled": False, "service": "pixeldrain"},
|
|
||||||
"url": track_url
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(f"https://{self.base_domain}/api/load?url=/api/fetch/stream/v2",
|
|
||||||
json=initial_request,
|
|
||||||
headers=self.headers)
|
|
||||||
|
|
||||||
csrf_token = response.cookies.get('csrf_token')
|
|
||||||
if csrf_token:
|
|
||||||
self.headers['X-CSRF-Token'] = csrf_token
|
|
||||||
|
|
||||||
initial_response = response.json()
|
|
||||||
|
|
||||||
if not initial_response.get("success", False):
|
|
||||||
raise Exception(f"Initial request failed: {initial_response.get('error', 'Unknown error')}")
|
|
||||||
|
|
||||||
handoff = initial_response["handoff"]
|
|
||||||
server = initial_response["server"]
|
|
||||||
|
|
||||||
file_name = self.generate_filename(metadata)
|
|
||||||
|
|
||||||
completion_url = f"https://{server}.{self.base_domain}/api/fetch/request/{handoff}"
|
|
||||||
|
|
||||||
print("Waiting for track processing to complete")
|
|
||||||
while True:
|
|
||||||
completion_response = self.client.get(completion_url, headers=self.headers).json()
|
|
||||||
|
|
||||||
status = completion_response["status"]
|
|
||||||
if status == "completed":
|
|
||||||
print("Processing completed: 100%")
|
|
||||||
break
|
|
||||||
elif status == "error":
|
|
||||||
raise Exception(f"API request failed: {completion_response.get('message', 'Unknown error')}")
|
|
||||||
else:
|
|
||||||
progress = completion_response.get("progress", {})
|
|
||||||
if progress:
|
|
||||||
current = progress.get("current", 0)
|
|
||||||
total = progress.get("total", 100)
|
|
||||||
percent = int((current / total) * 100) if total > 0 else 0
|
|
||||||
action = progress.get("action", "Processing")
|
|
||||||
print(f"Progress: {percent}% - {action} ({current}/{total})")
|
|
||||||
|
|
||||||
if action.lower() == "metadata":
|
|
||||||
if self.progress_callback:
|
|
||||||
self.progress_callback(0, 0)
|
|
||||||
else:
|
|
||||||
print(f"Status: {status} - Waiting for progress information...")
|
|
||||||
if status.lower() == "metadata":
|
|
||||||
if self.progress_callback:
|
|
||||||
self.progress_callback(0, 0)
|
|
||||||
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
download_url = f"https://{server}.{self.base_domain}/api/fetch/request/{handoff}/download"
|
|
||||||
print(f"Starting download of: {file_name}")
|
|
||||||
|
|
||||||
response = self.client.get(download_url, stream=True, headers=self.headers)
|
|
||||||
total_size = int(response.headers.get('content-length', 0))
|
|
||||||
downloaded_size = 0
|
|
||||||
|
|
||||||
file_path = os.path.join(output_dir, file_name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(file_path, 'wb') as file:
|
|
||||||
start_time = time.time()
|
|
||||||
last_update_time = start_time
|
|
||||||
|
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
|
||||||
if chunk:
|
|
||||||
file.write(chunk)
|
|
||||||
downloaded_size += len(chunk)
|
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
if current_time - last_update_time >= 1:
|
|
||||||
if total_size > 0:
|
|
||||||
progress_percent = (downloaded_size / total_size) * 100
|
|
||||||
elapsed_time = current_time - start_time
|
|
||||||
speed = downloaded_size / (1024 * 1024 * elapsed_time) if elapsed_time > 0 else 0
|
|
||||||
print(f"Download progress: {progress_percent:.2f}% ({downloaded_size}/{total_size}) - {speed:.2f} MB/s")
|
|
||||||
else:
|
|
||||||
print(f"Downloaded {downloaded_size / (1024 * 1024):.2f} MB")
|
|
||||||
|
|
||||||
last_update_time = current_time
|
|
||||||
|
|
||||||
if self.progress_callback:
|
|
||||||
self.progress_callback(downloaded_size, total_size)
|
|
||||||
|
|
||||||
if downloaded_size == 0:
|
|
||||||
raise Exception("No data received from server")
|
|
||||||
|
|
||||||
print(f"Download completed: {file_path}")
|
|
||||||
return file_path
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if os.path.exists(file_path) and os.path.getsize(file_path) == 0:
|
|
||||||
try:
|
|
||||||
os.remove(file_path)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
raise e
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
use_fallback = False
|
|
||||||
downloader = TrackDownloader(use_fallback)
|
|
||||||
|
|
||||||
output_dir = "."
|
|
||||||
track_id = "2plbrEY59IikOBgBGLjaoe"
|
|
||||||
service = "amazon"
|
|
||||||
|
|
||||||
def progress_update(current, total):
|
|
||||||
if total > 0:
|
|
||||||
percent = (current / total) * 100
|
|
||||||
print(f"\rDownload progress: {percent:.2f}% ({current}/{total})", end="")
|
|
||||||
|
|
||||||
downloader.set_progress_callback(progress_update)
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"Getting track info for ID: {track_id} from {service}")
|
|
||||||
metadata = await downloader.get_track_info(track_id, service)
|
|
||||||
print(f"Track info received, starting download process")
|
|
||||||
|
|
||||||
downloaded_file = downloader.download(metadata, output_dir)
|
|
||||||
print(f"\nFile downloaded successfully: {downloaded_file}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"An error occurred: {str(e)}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from mutagen.flac import FLAC, Picture
|
||||||
|
from mutagen.id3 import PictureType
|
||||||
|
|
||||||
|
class ProgressCallback:
|
||||||
|
def __call__(self, current, total):
|
||||||
|
if total > 0:
|
||||||
|
percent = (current / total) * 100
|
||||||
|
print(f"\r{percent:.2f}% ({current}/{total})", end="")
|
||||||
|
else:
|
||||||
|
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
||||||
|
|
||||||
|
class QobuzDownloader:
|
||||||
|
def __init__(self, region="us", timeout=30):
|
||||||
|
if region not in ["eu", "us"]:
|
||||||
|
raise ValueError("Region must be either 'us' or 'eu'")
|
||||||
|
|
||||||
|
self.region = region
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||||
|
}
|
||||||
|
self.base_api_url = f"https://{region}.qobuz.squid.wtf/api"
|
||||||
|
self.download_chunk_size = 256 * 1024
|
||||||
|
self.progress_callback = ProgressCallback()
|
||||||
|
|
||||||
|
def set_progress_callback(self, callback):
|
||||||
|
self.progress_callback = callback
|
||||||
|
|
||||||
|
def sanitize_filename(self, filename):
|
||||||
|
if not filename:
|
||||||
|
return "Unknown Track"
|
||||||
|
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
||||||
|
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
||||||
|
|
||||||
|
def get_track_info(self, isrc):
|
||||||
|
print(f"Fetching: {isrc}")
|
||||||
|
search_url = f"{self.base_api_url}/get-music"
|
||||||
|
params = {'q': isrc, 'offset': 0, 'limit': 10}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(search_url, params=params, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
selected_track = None
|
||||||
|
if data and data.get("success"):
|
||||||
|
items = data.get("data", {}).get("tracks", {}).get("items", [])
|
||||||
|
priority = {24: 1, 16: 2}
|
||||||
|
for track in items:
|
||||||
|
if track.get("isrc") == isrc:
|
||||||
|
current_prio = priority.get(track.get("maximum_bit_depth"), 3)
|
||||||
|
if selected_track is None or current_prio < priority.get(selected_track.get("maximum_bit_depth"), 3):
|
||||||
|
selected_track = track
|
||||||
|
if current_prio == 1:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not selected_track:
|
||||||
|
raise Exception(f"Track not found: {isrc}")
|
||||||
|
|
||||||
|
title = selected_track.get('title', 'Unknown')
|
||||||
|
bit_depth = selected_track.get('maximum_bit_depth', 'Unknown')
|
||||||
|
print(f"Found: {title} ({bit_depth}b)")
|
||||||
|
return selected_track
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"Request error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error: {e}")
|
||||||
|
|
||||||
|
def get_download_url(self, track_id):
|
||||||
|
print("Fetching URL...")
|
||||||
|
download_api_url = f"{self.base_api_url}/download-music"
|
||||||
|
params = {'track_id': track_id, 'quality': 27}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(download_api_url, params=params, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data and data.get("success") and data.get("data", {}).get("url"):
|
||||||
|
download_url = data["data"]["url"]
|
||||||
|
print("URL found")
|
||||||
|
return download_url
|
||||||
|
else:
|
||||||
|
error_msg = data.get('error', {}).get('message', 'Unknown API error')
|
||||||
|
raise Exception(f"API error: {error_msg}")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"Request error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error: {e}")
|
||||||
|
|
||||||
|
def download(self, isrc, output_dir=".", is_paused_callback=None, is_stopped_callback=None):
|
||||||
|
if output_dir != ".":
|
||||||
|
try:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
raise Exception(f"Directory error: {e}")
|
||||||
|
|
||||||
|
track_info = self.get_track_info(isrc)
|
||||||
|
track_id = track_info.get("id")
|
||||||
|
|
||||||
|
if not track_id:
|
||||||
|
raise Exception("No track ID found")
|
||||||
|
|
||||||
|
artist_name = self.sanitize_filename(track_info.get('performer', {}).get('name'))
|
||||||
|
track_title = self.sanitize_filename(track_info.get('title'))
|
||||||
|
output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
|
||||||
|
|
||||||
|
if os.path.exists(output_filename):
|
||||||
|
file_size = os.path.getsize(output_filename)
|
||||||
|
if file_size > 0:
|
||||||
|
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
||||||
|
return output_filename
|
||||||
|
|
||||||
|
download_url = self.get_download_url(track_id)
|
||||||
|
temp_filename = output_filename + ".part"
|
||||||
|
|
||||||
|
print(f"Downloading...")
|
||||||
|
try:
|
||||||
|
with self.session.get(download_url, stream=True, timeout=900) as response, \
|
||||||
|
open(temp_filename, 'wb') as f:
|
||||||
|
response.raise_for_status()
|
||||||
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
|
downloaded_size = 0
|
||||||
|
start_time = time.time()
|
||||||
|
last_update_time = start_time
|
||||||
|
|
||||||
|
for chunk in response.iter_content(chunk_size=self.download_chunk_size):
|
||||||
|
if is_stopped_callback and is_stopped_callback():
|
||||||
|
f.close()
|
||||||
|
if os.path.exists(temp_filename):
|
||||||
|
os.remove(temp_filename)
|
||||||
|
raise Exception("Download stopped")
|
||||||
|
|
||||||
|
while is_paused_callback and is_paused_callback():
|
||||||
|
time.sleep(0.1)
|
||||||
|
if is_stopped_callback and is_stopped_callback():
|
||||||
|
f.close()
|
||||||
|
if os.path.exists(temp_filename):
|
||||||
|
os.remove(temp_filename)
|
||||||
|
raise Exception("Download stopped")
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded_size += len(chunk)
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - last_update_time >= 1:
|
||||||
|
if total_size > 0:
|
||||||
|
progress_percent = (downloaded_size / total_size) * 100
|
||||||
|
elapsed_time = current_time - start_time
|
||||||
|
speed = downloaded_size / (1024 * 1024 * elapsed_time) if elapsed_time > 0 else 0
|
||||||
|
print(f"{progress_percent:.2f}% - {speed:.2f} MB/s")
|
||||||
|
else:
|
||||||
|
print(f"{downloaded_size / (1024 * 1024):.2f} MB")
|
||||||
|
|
||||||
|
last_update_time = current_time
|
||||||
|
|
||||||
|
if self.progress_callback:
|
||||||
|
self.progress_callback(downloaded_size, total_size)
|
||||||
|
|
||||||
|
os.rename(temp_filename, output_filename)
|
||||||
|
print("Download complete")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if os.path.exists(temp_filename):
|
||||||
|
os.remove(temp_filename)
|
||||||
|
raise Exception(f"Download failed: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
if os.path.exists(temp_filename):
|
||||||
|
os.remove(temp_filename)
|
||||||
|
raise Exception(f"File error: {e}")
|
||||||
|
|
||||||
|
print("Adding metadata...")
|
||||||
|
try:
|
||||||
|
self._embed_metadata(output_filename, track_info)
|
||||||
|
print("Metadata saved")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Tagging failed: {e}")
|
||||||
|
|
||||||
|
print(f"Done")
|
||||||
|
return output_filename
|
||||||
|
|
||||||
|
def _embed_metadata(self, filename, track_info):
|
||||||
|
try:
|
||||||
|
audio = FLAC(filename)
|
||||||
|
audio.delete()
|
||||||
|
audio.clear_pictures()
|
||||||
|
|
||||||
|
album_info = track_info.get('album', {})
|
||||||
|
artist = track_info.get('performer', {}).get('name')
|
||||||
|
|
||||||
|
if track_info.get('title'):
|
||||||
|
audio['TITLE'] = track_info['title']
|
||||||
|
if artist:
|
||||||
|
audio['ARTIST'] = artist
|
||||||
|
if album_info.get('title'):
|
||||||
|
audio['ALBUM'] = album_info['title']
|
||||||
|
if album_info.get('artist', {}).get('name', artist):
|
||||||
|
audio['ALBUMARTIST'] = album_info.get('artist', {}).get('name', artist)
|
||||||
|
if track_info.get('track_number'):
|
||||||
|
audio['TRACKNUMBER'] = str(track_info['track_number'])
|
||||||
|
if track_info.get('release_date_original'):
|
||||||
|
audio['DATE'] = track_info['release_date_original']
|
||||||
|
try:
|
||||||
|
audio['YEAR'] = str(datetime.strptime(track_info['release_date_original'], '%Y-%m-%d').year)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if album_info.get('genre', {}).get('name'):
|
||||||
|
audio['GENRE'] = album_info['genre']['name']
|
||||||
|
if track_info.get('copyright'):
|
||||||
|
audio['COPYRIGHT'] = track_info['copyright']
|
||||||
|
if track_info.get('isrc'):
|
||||||
|
audio['ISRC'] = track_info['isrc']
|
||||||
|
if album_info.get('label', {}).get('name'):
|
||||||
|
audio['ORGANIZATION'] = album_info['label']['name']
|
||||||
|
|
||||||
|
img_info = album_info.get('image', {})
|
||||||
|
cover_url = img_info.get('large') or img_info.get('small') or img_info.get('thumbnail')
|
||||||
|
if cover_url:
|
||||||
|
try:
|
||||||
|
img_response = self.session.get(cover_url, timeout=30)
|
||||||
|
img_response.raise_for_status()
|
||||||
|
mime_type = img_response.headers.get('Content-Type', 'image/jpeg').lower()
|
||||||
|
if mime_type in ['image/jpeg', 'image/png']:
|
||||||
|
picture = Picture()
|
||||||
|
picture.data = img_response.content
|
||||||
|
picture.type = PictureType.COVER_FRONT
|
||||||
|
picture.mime = mime_type
|
||||||
|
audio.add_picture(picture)
|
||||||
|
print("Cover added")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Cover error: {str(e)}")
|
||||||
|
|
||||||
|
audio.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Metadata error: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=== QobuzDL - Qobuz Downloader ===")
|
||||||
|
downloader = QobuzDownloader(region="us")
|
||||||
|
|
||||||
|
isrc = "USAT22409172"
|
||||||
|
output_dir = "."
|
||||||
|
|
||||||
|
try:
|
||||||
|
downloaded_file = downloader.download(isrc, output_dir)
|
||||||
|
print(f"Success: File saved as {downloaded_file}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {str(e)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
import sys
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import os
|
||||||
|
os.system("chcp 65001 > nul")
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
main()
|
||||||
+428
@@ -0,0 +1,428 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import httpx
|
||||||
|
from mutagen.flac import FLAC, Picture
|
||||||
|
from mutagen.id3 import PictureType
|
||||||
|
|
||||||
|
class ProgressCallback:
|
||||||
|
def __call__(self, current, total):
|
||||||
|
if total > 0:
|
||||||
|
percent = (current / total) * 100
|
||||||
|
print(f"\r{percent:.2f}% ({current}/{total})", end="")
|
||||||
|
else:
|
||||||
|
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
||||||
|
|
||||||
|
class TidalDownloader:
|
||||||
|
def __init__(self, timeout=30, max_retries=3):
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.download_chunk_size = 256 * 1024
|
||||||
|
self.progress_callback = ProgressCallback()
|
||||||
|
self.client_id = "zU4XHVVkc2tDPo4t"
|
||||||
|
self.client_secret = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4="
|
||||||
|
|
||||||
|
def set_progress_callback(self, callback):
|
||||||
|
self.progress_callback = callback
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(self, filename):
|
||||||
|
if not filename:
|
||||||
|
return "Unknown Track"
|
||||||
|
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
||||||
|
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
||||||
|
|
||||||
|
async def get_access_token(self):
|
||||||
|
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(http2=True) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
url=refresh_url,
|
||||||
|
data=payload,
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
token_data = response.json()
|
||||||
|
return token_data.get("access_token")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_tracks(self, query):
|
||||||
|
try:
|
||||||
|
tidal_token = await self.get_access_token()
|
||||||
|
if not tidal_token:
|
||||||
|
raise Exception("Failed to get access token")
|
||||||
|
|
||||||
|
search_url = f"https://api.tidal.com/v1/search/tracks?query={query}&limit=25&offset=0&countryCode=US"
|
||||||
|
header = {"authorization": f"Bearer {tidal_token}"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(http2=True) as client:
|
||||||
|
search_data = await client.get(url=search_url, headers=header)
|
||||||
|
response_data = search_data.json()
|
||||||
|
|
||||||
|
filtered_items = [{
|
||||||
|
"id": item.get("id"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"url": item.get("url"),
|
||||||
|
"isrc": item.get("isrc"),
|
||||||
|
"audioQuality": item.get("audioQuality"),
|
||||||
|
"mediaMetadata": item.get("mediaMetadata"),
|
||||||
|
"album": item.get("album", {}),
|
||||||
|
"artists": item.get("artists", []),
|
||||||
|
"artist": item.get("artist", {}),
|
||||||
|
"trackNumber": item.get("trackNumber"),
|
||||||
|
"volumeNumber": item.get("volumeNumber"),
|
||||||
|
"duration": item.get("duration"),
|
||||||
|
"copyright": item.get("copyright"),
|
||||||
|
"explicit": item.get("explicit")
|
||||||
|
} for item in response_data.get("items", [])]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"limit": response_data.get("limit"),
|
||||||
|
"offset": response_data.get("offset"),
|
||||||
|
"totalNumberOfItems": response_data.get("totalNumberOfItems"),
|
||||||
|
"items": filtered_items
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Search error: {str(e)}")
|
||||||
|
|
||||||
|
async def get_track_info(self, query, isrc=None):
|
||||||
|
print(f"Fetching: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.search_tracks(query)
|
||||||
|
|
||||||
|
if not result or not result.get("items"):
|
||||||
|
raise Exception(f"No tracks found for query: {query}")
|
||||||
|
|
||||||
|
selected_track = None
|
||||||
|
if isrc:
|
||||||
|
isrc_items = [item for item in result["items"] if item.get("isrc") == isrc]
|
||||||
|
|
||||||
|
if len(isrc_items) > 1:
|
||||||
|
hires_items = []
|
||||||
|
for item in isrc_items:
|
||||||
|
media_metadata = item.get("mediaMetadata", {})
|
||||||
|
tags = media_metadata.get("tags", []) if media_metadata else []
|
||||||
|
if "HIRES_LOSSLESS" in tags:
|
||||||
|
hires_items.append(item)
|
||||||
|
|
||||||
|
if hires_items:
|
||||||
|
selected_track = hires_items[0]
|
||||||
|
else:
|
||||||
|
selected_track = isrc_items[0]
|
||||||
|
elif len(isrc_items) == 1:
|
||||||
|
selected_track = isrc_items[0]
|
||||||
|
else:
|
||||||
|
selected_track = result["items"][0]
|
||||||
|
else:
|
||||||
|
selected_track = result["items"][0]
|
||||||
|
|
||||||
|
if not selected_track:
|
||||||
|
raise Exception(f"Track not found: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
|
||||||
|
|
||||||
|
title = selected_track.get('title', 'Unknown')
|
||||||
|
quality = selected_track.get('audioQuality', 'Unknown')
|
||||||
|
print(f"Found: {title} ({quality})")
|
||||||
|
return selected_track
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error getting track info: {str(e)}")
|
||||||
|
|
||||||
|
async def get_download_url(self, track_id, quality="LOSSLESS"):
|
||||||
|
print("Fetching URL...")
|
||||||
|
download_api_url = f"https://hifi.401658.xyz/track/?id={track_id}&quality={quality}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(http2=True, timeout=self.timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(download_api_url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
if "OriginalTrackUrl" in item:
|
||||||
|
print("URL found")
|
||||||
|
return {
|
||||||
|
"download_url": item["OriginalTrackUrl"],
|
||||||
|
"track_info": data[0] if data else {}
|
||||||
|
}
|
||||||
|
|
||||||
|
raise Exception("Download URL not found in response")
|
||||||
|
else:
|
||||||
|
raise Exception(f"API returned status code: {response.status_code}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error getting download URL: {str(e)}")
|
||||||
|
|
||||||
|
async def download_album_art(self, album_id, size="1280x1280"):
|
||||||
|
try:
|
||||||
|
art_url = f"https://resources.tidal.com/images/{album_id.replace('-', '/')}/{size}.jpg"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(http2=True, timeout=self.timeout) as client:
|
||||||
|
response = await client.get(art_url)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.content
|
||||||
|
else:
|
||||||
|
print(f"Failed to download album art: HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading album art: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def download_file(self, url, filepath, is_paused_callback=None, is_stopped_callback=None):
|
||||||
|
temp_filepath = filepath + ".part"
|
||||||
|
retry_count = 0
|
||||||
|
|
||||||
|
while retry_count <= self.max_retries:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(http2=True, timeout=60.0) as client:
|
||||||
|
async with client.stream('GET', url) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"HTTP {response.status_code}")
|
||||||
|
|
||||||
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
|
downloaded_size = 0
|
||||||
|
start_time = time.time()
|
||||||
|
last_update_time = start_time
|
||||||
|
|
||||||
|
with open(temp_filepath, 'wb') as f:
|
||||||
|
async for chunk in response.aiter_bytes(chunk_size=self.download_chunk_size):
|
||||||
|
if is_stopped_callback and is_stopped_callback():
|
||||||
|
f.close()
|
||||||
|
if os.path.exists(temp_filepath):
|
||||||
|
os.remove(temp_filepath)
|
||||||
|
raise Exception("Download stopped")
|
||||||
|
|
||||||
|
while is_paused_callback and is_paused_callback():
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
if is_stopped_callback and is_stopped_callback():
|
||||||
|
f.close()
|
||||||
|
if os.path.exists(temp_filepath):
|
||||||
|
os.remove(temp_filepath)
|
||||||
|
raise Exception("Download stopped")
|
||||||
|
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded_size += len(chunk)
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - last_update_time >= 1:
|
||||||
|
if total_size > 0:
|
||||||
|
progress_percent = (downloaded_size / total_size) * 100
|
||||||
|
elapsed_time = current_time - start_time
|
||||||
|
speed = downloaded_size / (1024 * 1024 * elapsed_time) if elapsed_time > 0 else 0
|
||||||
|
print(f"{progress_percent:.2f}% - {speed:.2f} MB/s")
|
||||||
|
else:
|
||||||
|
print(f"{downloaded_size / (1024 * 1024):.2f} MB")
|
||||||
|
|
||||||
|
last_update_time = current_time
|
||||||
|
|
||||||
|
if self.progress_callback:
|
||||||
|
self.progress_callback(downloaded_size, total_size)
|
||||||
|
|
||||||
|
os.rename(temp_filepath, filepath)
|
||||||
|
print("Download complete")
|
||||||
|
return {"success": True, "size": downloaded_size}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
retry_count += 1
|
||||||
|
if retry_count > self.max_retries:
|
||||||
|
if os.path.exists(temp_filepath):
|
||||||
|
try:
|
||||||
|
os.remove(temp_filepath)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
raise Exception(f"Download error after {self.max_retries} retries: {str(e)}")
|
||||||
|
|
||||||
|
print(f"Download error (attempt {retry_count}/{self.max_retries}): {str(e)}")
|
||||||
|
print(f"Retrying in {retry_count * 2} seconds...")
|
||||||
|
await asyncio.sleep(retry_count * 2)
|
||||||
|
|
||||||
|
async def embed_metadata(self, filepath, track_info, search_info=None):
|
||||||
|
try:
|
||||||
|
print("Embedding metadata...")
|
||||||
|
audio = FLAC(filepath)
|
||||||
|
audio.clear()
|
||||||
|
audio.clear_pictures()
|
||||||
|
|
||||||
|
if track_info.get("title"):
|
||||||
|
audio["TITLE"] = track_info["title"]
|
||||||
|
|
||||||
|
artists_list = []
|
||||||
|
if search_info and search_info.get("artists"):
|
||||||
|
for artist in search_info["artists"]:
|
||||||
|
if artist.get("name"):
|
||||||
|
artists_list.append(artist["name"])
|
||||||
|
elif search_info and search_info.get("artist") and search_info["artist"].get("name"):
|
||||||
|
artists_list.append(search_info["artist"]["name"])
|
||||||
|
elif track_info.get("artists"):
|
||||||
|
for artist in track_info["artists"]:
|
||||||
|
if artist.get("name"):
|
||||||
|
artists_list.append(artist["name"])
|
||||||
|
elif track_info.get("artist") and track_info["artist"].get("name"):
|
||||||
|
artists_list.append(track_info["artist"]["name"])
|
||||||
|
|
||||||
|
if artists_list:
|
||||||
|
audio["ARTIST"] = artists_list[0]
|
||||||
|
if len(artists_list) > 1:
|
||||||
|
audio["ALBUMARTIST"] = "; ".join(artists_list)
|
||||||
|
else:
|
||||||
|
audio["ALBUMARTIST"] = artists_list[0]
|
||||||
|
|
||||||
|
album_info = search_info.get("album", {}) if search_info else track_info.get("album", {})
|
||||||
|
if album_info.get("title"):
|
||||||
|
audio["ALBUM"] = album_info["title"]
|
||||||
|
|
||||||
|
if search_info and search_info.get("trackNumber"):
|
||||||
|
audio["TRACKNUMBER"] = str(search_info["trackNumber"])
|
||||||
|
elif track_info.get("trackNumber"):
|
||||||
|
audio["TRACKNUMBER"] = str(track_info["trackNumber"])
|
||||||
|
|
||||||
|
if search_info and search_info.get("volumeNumber"):
|
||||||
|
audio["DISCNUMBER"] = str(search_info["volumeNumber"])
|
||||||
|
elif track_info.get("volumeNumber"):
|
||||||
|
audio["DISCNUMBER"] = str(track_info["volumeNumber"])
|
||||||
|
|
||||||
|
duration = search_info.get("duration") if search_info else track_info.get("duration")
|
||||||
|
if duration:
|
||||||
|
audio["LENGTH"] = str(duration)
|
||||||
|
|
||||||
|
isrc = search_info.get("isrc") if search_info else track_info.get("isrc")
|
||||||
|
if isrc:
|
||||||
|
audio["ISRC"] = isrc
|
||||||
|
|
||||||
|
copyright_info = search_info.get("copyright") if search_info else track_info.get("copyright")
|
||||||
|
if copyright_info:
|
||||||
|
audio["COPYRIGHT"] = copyright_info
|
||||||
|
|
||||||
|
if album_info.get("releaseDate"):
|
||||||
|
audio["DATE"] = album_info["releaseDate"][:4]
|
||||||
|
try:
|
||||||
|
audio["YEAR"] = album_info["releaseDate"][:4]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if track_info.get("genre"):
|
||||||
|
audio["GENRE"] = track_info["genre"]
|
||||||
|
|
||||||
|
if track_info.get("audioQuality"):
|
||||||
|
audio["COMMENT"] = f"Tidal {track_info['audioQuality']}"
|
||||||
|
|
||||||
|
if album_info.get("cover"):
|
||||||
|
album_art = await self.download_album_art(album_info["cover"])
|
||||||
|
if album_art:
|
||||||
|
picture = Picture()
|
||||||
|
picture.data = album_art
|
||||||
|
picture.type = PictureType.COVER_FRONT
|
||||||
|
picture.mime = "image/jpeg"
|
||||||
|
picture.desc = "Cover"
|
||||||
|
audio.add_picture(picture)
|
||||||
|
print("Album art embedded")
|
||||||
|
|
||||||
|
audio.save()
|
||||||
|
print(f"Metadata embedded successfully for: {track_info.get('title', 'Unknown')}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error embedding metadata: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None):
|
||||||
|
if output_dir != ".":
|
||||||
|
try:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
raise Exception(f"Directory error: {e}")
|
||||||
|
|
||||||
|
track_info = await self.get_track_info(query, isrc)
|
||||||
|
track_id = track_info.get("id")
|
||||||
|
|
||||||
|
if not track_id:
|
||||||
|
raise Exception("No track ID found")
|
||||||
|
|
||||||
|
artists_list = []
|
||||||
|
if track_info.get("artists"):
|
||||||
|
for artist in track_info["artists"]:
|
||||||
|
if artist.get("name"):
|
||||||
|
artists_list.append(artist["name"])
|
||||||
|
elif track_info.get("artist") and track_info["artist"].get("name"):
|
||||||
|
artists_list.append(track_info["artist"]["name"])
|
||||||
|
|
||||||
|
artist_name = ", ".join(artists_list) if artists_list else "Unknown Artist"
|
||||||
|
artist_name = self.sanitize_filename(artist_name)
|
||||||
|
track_title = self.sanitize_filename(track_info.get("title", f"track_{track_id}"))
|
||||||
|
|
||||||
|
output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
|
||||||
|
|
||||||
|
if os.path.exists(output_filename):
|
||||||
|
file_size = os.path.getsize(output_filename)
|
||||||
|
if file_size > 0:
|
||||||
|
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
||||||
|
return output_filename
|
||||||
|
|
||||||
|
download_info = await self.get_download_url(track_id, quality)
|
||||||
|
download_url = download_info["download_url"]
|
||||||
|
download_track_info = download_info["track_info"]
|
||||||
|
|
||||||
|
print(f"Downloading to: {output_filename}")
|
||||||
|
await self.download_file(
|
||||||
|
download_url,
|
||||||
|
output_filename,
|
||||||
|
is_paused_callback=is_paused_callback,
|
||||||
|
is_stopped_callback=is_stopped_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Adding metadata...")
|
||||||
|
try:
|
||||||
|
await self.embed_metadata(output_filename, download_track_info, track_info)
|
||||||
|
print("Metadata saved")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Tagging failed: {e}")
|
||||||
|
|
||||||
|
print("Done")
|
||||||
|
return output_filename
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=== TidalDL - Tidal Downloader ===")
|
||||||
|
downloader = TidalDownloader(timeout=30, max_retries=3)
|
||||||
|
|
||||||
|
query = "APT."
|
||||||
|
isrc = "USAT22409172"
|
||||||
|
output_dir = "."
|
||||||
|
|
||||||
|
try:
|
||||||
|
downloaded_file = await downloader.download(query, isrc, output_dir)
|
||||||
|
print(f"Success: File saved as {downloaded_file}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {str(e)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
import sys
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import os
|
||||||
|
os.system("chcp 65001 > nul")
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "2.1"
|
"version": "4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user