Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49a8de1b35 | |||
| cd2500d1df | |||
| ea1372f1fe | |||
| 65fbb9a8e9 | |||
| de16d9e25d | |||
| 6dd19b563b | |||
| 303b76d1ec | |||
| dbcd49225d | |||
| bdc7717ef3 | |||
| 9a7c539418 | |||
| 888ce2b61c | |||
| e2e1ab1cfa | |||
| 9bddeab0d1 | |||
| 03a30ee09a | |||
| 2d908e2f75 | |||
| e8f7bf7313 |
@@ -3,20 +3,20 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz & Tidal.
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal, Deezer & Amazon Music.
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v3.9/SpotiFLAC.exe)
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.4/SpotiFLAC.exe)
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## Lossless Audio Check
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,128 @@
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
import urllib3
|
||||
from urllib.parse import unquote
|
||||
from random import randrange
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
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 extract_data(html, patterns):
|
||||
for pattern in patterns:
|
||||
if match := re.search(pattern, html):
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def download_track(track_id, service="amazon", output_dir="."):
|
||||
client = requests.Session()
|
||||
client.verify = False
|
||||
headers = {'User-Agent': get_random_user_agent()}
|
||||
|
||||
try:
|
||||
spotify_url = f"https://open.spotify.com/track/{track_id}"
|
||||
params = {"url": spotify_url, "country": "auto", "to": service}
|
||||
|
||||
response = client.get("https://lucida.to", params=params, headers=headers, timeout=30)
|
||||
html = response.text
|
||||
|
||||
token = extract_data(html, [r'token:"([^"]+)"', r'"token"\s*:\s*"([^"]+)"'])
|
||||
url = extract_data(html, [r'"url":"([^"]+)"', r'url:"([^"]+)"'])
|
||||
expiry = extract_data(html, [r'tokenExpiry:(\d+)', r'"tokenExpiry"\s*:\s*(\d+)'])
|
||||
|
||||
if not (token and url):
|
||||
raise Exception("Could not extract required data")
|
||||
|
||||
try:
|
||||
decoded_token = base64.b64decode(base64.b64decode(token).decode('latin1')).decode('latin1')
|
||||
except:
|
||||
decoded_token = token
|
||||
|
||||
clean_url = url.replace('\\/', '/')
|
||||
print(f"Fetching: {clean_url}")
|
||||
|
||||
request_data = {
|
||||
"account": {"id": "auto", "type": "country"},
|
||||
"compat": "false", "downscale": "original", "handoff": True,
|
||||
"metadata": True, "private": True,
|
||||
"token": {"primary": decoded_token, "expiry": int(expiry) if expiry else None},
|
||||
"upload": {"enabled": False, "service": "pixeldrain"},
|
||||
"url": clean_url
|
||||
}
|
||||
|
||||
response = client.post("https://lucida.to/api/load?url=/api/fetch/stream/v2",
|
||||
json=request_data, headers=headers)
|
||||
|
||||
if csrf_token := response.cookies.get('csrf_token'):
|
||||
headers['X-CSRF-Token'] = csrf_token
|
||||
|
||||
data = response.json()
|
||||
if not data.get("success"):
|
||||
raise Exception(f"Request failed: {data.get('error', 'Unknown error')}")
|
||||
|
||||
completion_url = f"https://{data['server']}.lucida.to/api/fetch/request/{data['handoff']}"
|
||||
print("Fetching URL...")
|
||||
|
||||
while True:
|
||||
resp = client.get(completion_url, headers=headers).json()
|
||||
if resp["status"] == "completed":
|
||||
print("URL found")
|
||||
break
|
||||
elif resp["status"] == "error":
|
||||
raise Exception(f"Processing failed: {resp.get('message', 'Unknown error')}")
|
||||
elif progress := resp.get("progress"):
|
||||
percent = int((progress.get("current", 0) / progress.get("total", 100)) * 100)
|
||||
print(f"\r{percent}%", end="")
|
||||
time.sleep(1)
|
||||
|
||||
download_url = f"https://{data['server']}.lucida.to/api/fetch/request/{data['handoff']}/download"
|
||||
response = client.get(download_url, stream=True, headers=headers)
|
||||
|
||||
file_name = "track.flac"
|
||||
if content_disp := response.headers.get('content-disposition'):
|
||||
if match := re.search(r'filename[*]?=([^;]+)', content_disp):
|
||||
raw_name = match.group(1).strip('"\'')
|
||||
file_name = unquote(raw_name[7:] if raw_name.startswith("UTF-8''") else raw_name)
|
||||
for char in '<>:"/\\|?*':
|
||||
file_name = file_name.replace(char, '')
|
||||
file_name = file_name.strip()
|
||||
|
||||
file_path = os.path.join(output_dir, file_name)
|
||||
print(f"Downloading...")
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
print("Download complete")
|
||||
print("Done")
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return None
|
||||
|
||||
class LucidaDownloader:
|
||||
def __init__(self):
|
||||
self.progress_callback = None
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
def download(self, track_id, output_dir, is_paused_callback=None, is_stopped_callback=None):
|
||||
try:
|
||||
return download_track(track_id, service="amazon", output_dir=output_dir)
|
||||
except Exception as e:
|
||||
raise Exception(f"Amazon Music download failed: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== AmazonDL - Amazon Music Downloader ===")
|
||||
track_id = "2plbrEY59IikOBgBGLjaoe"
|
||||
service = "amazon"
|
||||
|
||||
download_track(track_id, service)
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-au" viewBox="0 0 640 480">
|
||||
<path fill="#00008B" d="M0 0h640v480H0z"/>
|
||||
<path fill="#fff" d="m37.5 0 122 90.5L281 0h39v31l-120 89.5 120 89V240h-40l-120-89.5L40.5 240H0v-30l119.5-89L0 32V0z"/>
|
||||
<path fill="red" d="M212 140.5 320 220v20l-135.5-99.5zm-92 10 3 17.5-96 72H0zM320 0v1.5l-124.5 94 1-22L295 0zM0 0l119.5 88h-30L0 21z"/>
|
||||
<path fill="#fff" d="M120.5 0v240h80V0zM0 80v80h320V80z"/>
|
||||
<path fill="red" d="M0 96.5v48h320v-48zM136.5 0v240h48V0z"/>
|
||||
<path fill="#fff" d="m527 396.7-20.5 2.6 2.2 20.5-14.8-14.4-14.7 14.5 2-20.5-20.5-2.4 17.3-11.2-10.9-17.5 19.6 6.5 6.9-19.5 7.1 19.4 19.5-6.7-10.7 17.6zm-3.7-117.2 2.7-13-9.8-9 13.2-1.5 5.5-12.1 5.5 12.1 13.2 1.5-9.8 9 2.7 13-11.6-6.6zm-104.1-60-20.3 2.2 1.8 20.3-14.4-14.5-14.8 14.1 2.4-20.3-20.2-2.7 17.3-10.8-10.5-17.5 19.3 6.8L387 178l6.7 19.3 19.4-6.3-10.9 17.3 17.1 11.2ZM623 186.7l-20.9 2.7 2.3 20.9-15.1-14.7-15 14.8 2.1-21-20.9-2.4 17.7-11.5-11.1-17.9 20 6.7 7-19.8 7.2 19.8 19.9-6.9-11 18zm-96.1-83.5-20.7 2.3 1.9 20.8-14.7-14.8-15.1 14.4 2.4-20.7-20.7-2.8 17.7-11L467 73.5l19.7 6.9 7.3-19.5 6.8 19.7 19.8-6.5-11.1 17.6zM234 385.7l-45.8 5.4 4.6 45.9-32.8-32.4-33 32.2 4.9-45.9-45.8-5.8 38.9-24.8-24-39.4 43.6 15 15.8-43.4 15.5 43.5 43.7-14.7-24.3 39.2 38.8 25.1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-br" viewBox="0 0 640 480">
|
||||
<g stroke-width="1pt">
|
||||
<path fill="#229e45" fill-rule="evenodd" d="M0 0h640v480H0z"/>
|
||||
<path fill="#f8e509" fill-rule="evenodd" d="m321.4 436 301.5-195.7L319.6 44 17.1 240.7z"/>
|
||||
<path fill="#2b49a3" fill-rule="evenodd" d="M452.8 240c0 70.3-57.1 127.3-127.6 127.3A127.4 127.4 0 1 1 452.8 240"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m283.3 316.3-4-2.3-4 2 .9-4.5-3.2-3.4 4.5-.5 2.2-4 1.9 4.2 4.4.8-3.3 3m86 26.3-3.9-2.3-4 2 .8-4.5-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.1m-36.2-30-3.4-2-3.5 1.8.8-3.9-2.8-2.9 4-.4 1.8-3.6 1.6 3.7 3.9.7-3 2.7m87-8.5-3.4-2-3.5 1.8.8-3.9-2.7-2.8 3.9-.4 1.8-3.5 1.6 3.6 3.8.7-2.9 2.6m-87.3-22-4-2.2-4 2 .8-4.6-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.2m-104.6-35-4-2.2-4 2 1-4.6-3.3-3.3 4.6-.5 2-4.1 2 4.2 4.4.8-3.3 3.1m13.3 57.2-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.1-4 2 4.2 4.4.8-3.3 3.1m132-67.3-3.6-2-3.6 1.8.8-4-2.8-3 4-.5 1.9-3.6 1.7 3.8 4 .7-3 2.7m-6.7 38.3-2.7-1.6-2.9 1.4.6-3.2-2.2-2.3 3.2-.4 1.5-2.8 1.3 3 3 .5-2.2 2.2m-142.2 50.4-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2M419 299.8l-2.2-1.1-2.2 1 .5-2.3-1.7-1.6 2.4-.3 1.2-2 1 2 2.5.5-1.9 1.5"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m42.3 3-2.6-1.4-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .5-2.3 2.1m-4.8 17-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m87.4-22.2-2.6-1.6-2.8 1.4.6-3-2-2.3 3-.3 1.4-2.7 1.2 2.8 3 .5-2.2 2.1m-25.1 3-2.7-1.5-2.7 1.4.6-3-2-2.3 3-.3 1.4-2.8 1.2 2.9 3 .5-2.2 2.1m-68.8-5.8-1.7-1-1.7.8.4-1.9-1.3-1.4 1.9-.2.8-1.7.8 1.8 1.9.3-1.4 1.3m167.8 45.4-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m-20.8 6-2.2-1.4-2.3 1.2.5-2.6-1.7-1.8 2.5-.3 1.2-2.3 1 2.4 2.5.4-1.9 1.8m10.4 2.3-2-1.2-2.1 1 .4-2.3-1.6-1.7 2.3-.3 1.1-2 1 2 2.3.5-1.7 1.6m29.1-22.8-2-1-2 1 .5-2.3-1.6-1.7 2.3-.3 1-2 1 2.1 2.1.4-1.6 1.6m-38.8 41.8-2.5-1.4-2.7 1.2.6-2.8-2-2 3-.3 1.3-2.5 1.2 2.6 3 .5-2.3 1.9m.6 14.2-2.4-1.4-2.4 1.3.6-2.8-1.9-2 2.7-.4 1.2-2.5 1.1 2.6 2.7.5-2 2m-19-23.1-1.9-1.2-2 1 .4-2.2-1.5-1.7 2.2-.2 1-2 1 2 2.2.4-1.6 1.6m-17.8 2.3-2-1.2-2 1 .5-2.2-1.6-1.7 2.3-.2 1-2 1 2 2.1.4-1.6 1.6m-30.4-24.6-2-1.1-2 1 .5-2.3-1.6-1.6 2.2-.3 1-2 1 2 2.2.5-1.6 1.5m3.7 57-1.6-.9-1.8.9.4-2-1.3-1.4 1.9-.2.9-1.7.8 1.8 1.9.3-1.4 1.3m-46.2-86.6-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.2-4 1.9 4.2 4.4.8-3.3 3.1"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M444.4 285.8a125 125 0 0 0 5.8-19.8c-67.8-59.5-143.3-90-238.7-83.7a125 125 0 0 0-8.5 20.9c113-10.8 196 39.2 241.4 82.6"/>
|
||||
<path fill="#309e3a" d="m414 252.4 2.3 1.3a3 3 0 0 0-.3 2.2 3 3 0 0 0 1.4 1.7q1 .8 2 .7.9 0 1.3-.7l.2-.9-.5-1-1.5-1.8a8 8 0 0 1-1.8-3 4 4 0 0 1 2-4.4 4 4 0 0 1 2.3-.2 7 7 0 0 1 2.6 1.2q2.1 1.5 2.6 3.2a4 4 0 0 1-.6 3.3l-2.4-1.5q.5-1 .2-1.7-.2-.8-1.2-1.4a3 3 0 0 0-1.8-.7 1 1 0 0 0-.9.5q-.3.4-.1 1 .2.8 1.6 2.2t2 2.5a4 4 0 0 1-.3 4.2 4 4 0 0 1-1.9 1.5 4 4 0 0 1-2.4.3q-1.3-.3-2.8-1.3-2.2-1.5-2.7-3.3a5 5 0 0 1 .6-4zm-11.6-7.6 2.5 1.3a3 3 0 0 0-.2 2.2 3 3 0 0 0 1.4 1.6q1.1.8 2 .6.9 0 1.3-.8l.2-.8q0-.5-.5-1l-1.6-1.8q-1.7-1.6-2-2.8a4 4 0 0 1 .4-3.1 4 4 0 0 1 1.6-1.4 4 4 0 0 1 2.2-.3 7 7 0 0 1 2.6 1q2.3 1.5 2.7 3.1a4 4 0 0 1-.4 3.4l-2.5-1.4q.5-1 .2-1.7-.4-1-1.3-1.4a3 3 0 0 0-1.9-.6 1 1 0 0 0-.8.5q-.3.4-.1 1 .3.8 1.7 2.2 1.5 1.5 2 2.4a4 4 0 0 1 0 4.2 4 4 0 0 1-1.8 1.6 4 4 0 0 1-2.4.3 8 8 0 0 1-2.9-1.1 6 6 0 0 1-2.8-3.2 5 5 0 0 1 .4-4m-14.2-3.8 7.3-12 8.8 5.5-1.2 2-6.4-4-1.6 2.7 6 3.7-1.3 2-6-3.7-2 3.3 6.7 4-1.2 2zm-20.7-17 1.1-2 5.4 2.7-2.5 5q-1.2.3-3 .2a9 9 0 0 1-3.3-1 8 8 0 0 1-3-2.6 6 6 0 0 1-1-3.5 9 9 0 0 1 1-3.7 8 8 0 0 1 2.6-3 6 6 0 0 1 3.6-1.1q1.4 0 3.2 1 2.4 1.1 3.1 2.8a5 5 0 0 1 .3 3.5l-2.7-.8a3 3 0 0 0-.2-2q-.4-.9-1.6-1.4a4 4 0 0 0-3.1-.3q-1.5.5-2.6 2.6t-.7 3.8a4 4 0 0 0 2 2.4q.8.5 1.7.5h1.8l.8-1.6zm-90.2-22.3 2-14 4.2.7 1.1 9.8 3.9-9 4.2.6-2 13.8-2.7-.4 1.7-10.9-4.4 10.5-2.7-.4-1.1-11.3-1.6 11zm-14.1-1.7 1.3-14 10.3 1-.2 2.4-7.5-.7-.3 3 7 .7-.3 2.4-7-.7-.3 3.8 7.8.7-.2 2.4z"/>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M216.5 191.3q0-2.2.7-3.6a7 7 0 0 1 1.4-1.9 5 5 0 0 1 1.8-1.2q1.5-.5 3-.5 3.1.1 5 2a7 7 0 0 1 1.6 5.5q0 3.3-2 5.3a7 7 0 0 1-5 1.7 7 7 0 0 1-4.8-2 7 7 0 0 1-1.7-5.3"/>
|
||||
<path fill="#f7ffff" d="M219.4 191.3q0 2.3 1 3.6t2.8 1.3a4 4 0 0 0 2.8-1.1q1-1.2 1.1-3.7.1-2.4-1-3.6a4 4 0 0 0-2.7-1.3 4 4 0 0 0-2.8 1.2q-1.1 1.2-1.2 3.6"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m233 198.5.2-14h6q2.2 0 3.2.5 1 .3 1.6 1.3c.6 1 .6 1.4.6 2.3a4 4 0 0 1-1 2.6 5 5 0 0 1-2.7 1.2l1.5 1.2q.6.6 1.5 2.3l1.7 2.8h-3.4l-2-3.2-1.4-2-.9-.6-1.4-.2h-.6v5.8z"/>
|
||||
<path fill="#fff" d="M236 190.5h2q2.1 0 2.6-.2.5-.1.8-.5.4-.6.3-1 0-.9-.4-1.2-.3-.4-1-.6h-2l-2.3-.1z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m249 185.2 5.2.3q1.7 0 2.6.3a5 5 0 0 1 2 1.4 6 6 0 0 1 1.2 2.4q.4 1.4.3 3.3a9 9 0 0 1-.5 3q-.6 1.5-1.7 2.4a5 5 0 0 1-2 1q-1 .3-2.5.2l-5.3-.3z"/>
|
||||
<path fill="#fff" d="m251.7 187.7-.5 9.3h3.8q.8 0 1.2-.5.5-.4.8-1.3t.4-2.6l-.1-2.5a3 3 0 0 0-.8-1.4l-1.2-.7-2.3-.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m317.6 210.2 3.3-13.6 4.4 1 3.2 1q1.1.6 1.6 1.9t.2 2.8q-.3 1.2-1 2a4 4 0 0 1-3 1.4q-1 0-3-.5l-1.7-.5-1.2 5.2z"/>
|
||||
<path fill="#fff" d="m323 199.6-.8 3.8 1.5.4q1.6.4 2.2.3a2 2 0 0 0 1.6-1.5q0-.7-.2-1.3a2 2 0 0 0-1-.9l-1.9-.5-1.3-.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m330.6 214.1 4.7-13.2 5.5 2q2.2.8 3 1.4.8.7 1 1.8c.2 1.1.2 1.5 0 2.3q-.6 1.5-1.8 2.2-1.2.6-3 .3.6.7 1 1.6l.8 2.7.6 3.1-3.1-1.1-1-3.6-.7-2.4-.6-.8q-.3-.4-1.3-.7l-.5-.2-2 5.6z"/>
|
||||
<path fill="#fff" d="m336 207.4 1.9.7q2 .7 2.5.7t.9-.3q.5-.3.6-.9.3-.6 0-1.2l-.8-.9-2-.7-2-.7-1.2 3.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M347 213.6a9 9 0 0 1 1.7-3.2l1.8-1.5 2-.7q1.5-.1 3.1.4a7 7 0 0 1 4.2 3.3q1.2 2.4.2 5.7a7 7 0 0 1-3.4 4.5q-2.3 1.3-5.2.4a7 7 0 0 1-4.2-3.3 7 7 0 0 1-.2-5.6"/>
|
||||
<path fill="#fff" d="M349.8 214.4q-.7 2.3 0 3.8c.7 1.5 1.2 1.6 2.3 2q1.5.5 3-.4 1.4-.8 2.1-3.2.8-2.2 0-3.7a4 4 0 0 0-2.2-2 4 4 0 0 0-3 .3q-1.5.8-2.2 3.2"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m374.3 233.1 6.4-12.4 5.3 2.7a10 10 0 0 1 2.7 1.9q.8.7.8 1.9c0 1.2 0 1.5-.4 2.2a4 4 0 0 1-2 2q-1.5.4-3.1-.2.6 1 .8 1.7.3.9.4 2.8l.2 3.2-3-1.5-.4-3.7-.3-2.5-.5-1-1.2-.7-.5-.3-2.7 5.2z"/>
|
||||
<path fill="#fff" d="m380.5 227.2 1.9 1q1.8 1 2.3 1t1-.2q.4-.2.7-.8t.2-1.2l-.7-1-1.8-1-2-1z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M426.1 258.7a9 9 0 0 1 2.5-2.6 7 7 0 0 1 2.2-.9 6 6 0 0 1 2.2 0q1.5.3 2.8 1.2a7 7 0 0 1 3 4.4q.4 2.6-1.4 5.5a7 7 0 0 1-4.5 3.3 7 7 0 0 1-5.2-1.1 7 7 0 0 1-3-4.4q-.4-2.7 1.4-5.4"/>
|
||||
<path fill="#fff" d="M428.6 260.3q-1.4 2-1.1 3.6a4 4 0 0 0 1.6 2.5q1.5 1 3 .6t2.9-2.4q1.4-2.1 1.1-3.6t-1.6-2.6c-1.4-1.1-2-.8-3-.5q-1.5.3-3 2.4z"/>
|
||||
</g>
|
||||
<path fill="#309e3a" d="m301.8 204.5 2.3-9.8 7.2 1.7-.3 1.6-5.3-1.2-.5 2.2 4.9 1.1-.4 1.7-4.9-1.2-.6 2.7 5.5 1.3-.4 1.6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -2,16 +2,17 @@ import requests
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.id3 import ID3NoHeaderError
|
||||
import deezmate
|
||||
from random import randrange
|
||||
|
||||
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)}"
|
||||
|
||||
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'
|
||||
'User-Agent': get_random_user_agent()
|
||||
})
|
||||
self.progress_callback = None
|
||||
|
||||
@@ -128,7 +129,7 @@ class DeezerDownloader:
|
||||
except Exception as e:
|
||||
print(f"Error embedding metadata: {e}")
|
||||
|
||||
async def download_by_isrc(self, isrc, output_dir=".", initial_delay=7.5):
|
||||
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)
|
||||
@@ -139,41 +140,56 @@ class DeezerDownloader:
|
||||
metadata = self.extract_metadata(track_data)
|
||||
print(f"Found track: {metadata.get('artists', 'Unknown')} - {metadata.get('title', 'Unknown')}")
|
||||
|
||||
deezer_link = metadata.get('deezer_link')
|
||||
if not deezer_link:
|
||||
print("No Deezer link found in track data")
|
||||
track_id = track_data.get('id')
|
||||
if not track_id:
|
||||
print("No track ID found in Deezer API response")
|
||||
return False
|
||||
|
||||
print(f"Using Deezer link: {deezer_link}")
|
||||
print(f"Using track ID: {track_id}")
|
||||
|
||||
flac_url = await deezmate.main(deezer_link, initial_delay)
|
||||
if not flac_url:
|
||||
print("Failed to get download URL from deezmate")
|
||||
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 = self.session.get(flac_url)
|
||||
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)
|
||||
f.write(response.content)
|
||||
|
||||
downloaded = len(response.content)
|
||||
print(f"File size: {downloaded} bytes ({downloaded / (1024*1024):.2f} MB)")
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded, downloaded)
|
||||
|
||||
print(f"Downloaded: {file_path}")
|
||||
|
||||
@@ -197,19 +213,29 @@ class DeezerDownloader:
|
||||
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]
|
||||
print("=== DeezerDL - Deezer Downloader ===")
|
||||
downloader = DeezerDownloader()
|
||||
|
||||
success = await downloader.download_by_isrc(isrc)
|
||||
isrc = "USAT22409172"
|
||||
output_dir = "."
|
||||
|
||||
success = await downloader.download_by_isrc(isrc, output_dir)
|
||||
if success:
|
||||
print("Download completed successfully!")
|
||||
else:
|
||||
print("Download failed!")
|
||||
|
||||
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,130 +0,0 @@
|
||||
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())
|
||||
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-eu" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<g id="eu-d">
|
||||
<g id="eu-b">
|
||||
<path id="eu-a" d="m0-1-.3 1 .5.1z"/>
|
||||
<use xlink:href="#eu-a" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
<g id="eu-c">
|
||||
<use xlink:href="#eu-b" transform="rotate(72)"/>
|
||||
<use xlink:href="#eu-b" transform="rotate(144)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-c" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<path fill="#039" d="M0 0h640v480H0z"/>
|
||||
<g fill="#fc0" transform="translate(320 242.3)scale(23.7037)">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="6"/>
|
||||
<g id="eu-e">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" x="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(-144 -2.3 -2.1)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(144 -2.1 -2.3)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -4.7 -2)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -5 .5)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-e" width="100%" height="100%" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -58,7 +58,7 @@ playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
|
||||
album_base_url = 'https://api.spotify.com/v1/albums/{}'
|
||||
track_base_url = 'https://api.spotify.com/v1/tracks/{}'
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'User-Agent': get_random_user_agent(),
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 28.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
|
||||
style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FF0000;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFF00;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
|
||||
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#2AA125;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g id="SVGRepo_bgCarrier">
|
||||
</g>
|
||||
<g id="SVGRepo_tracerCarrier">
|
||||
</g>
|
||||
<g id="Layer_x0020_1">
|
||||
<g id="_1818452274576">
|
||||
<g id="SVGRepo_bgCarrier_00000044893734704698182460000014884511992085247122_">
|
||||
</g>
|
||||
<g id="SVGRepo_tracerCarrier_00000067939915892718314930000001743019108017086612_">
|
||||
</g>
|
||||
<g id="SVGRepo_iconCarrier_00000176000695080737548300000008661679408724292005_">
|
||||
<path d="M407.1,227.2c-54.7-27.6-119.3-43.8-187.6-43.8c-38.9,0-76.5,5.2-112.3,15l3-0.7c-2.1,0.7-4.5,1.1-7,1.1
|
||||
c-13,0-23.5-10.5-23.5-23.5c0-10.5,6.8-19.3,16.3-22.4l0.2-0.1c90.9-26.9,240.6-21.8,335.4,34.6c7.3,4.4,12.1,12.3,12.1,21.3
|
||||
c0,4.4-1.2,8.6-3.2,12.2l0.1-0.1c-5,5.9-12.3,9.6-20.6,9.6c-4.7,0-9-1.2-12.8-3.3L407.1,227.2L407.1,227.2z M404.5,298.9
|
||||
c-3.4,5.7-9.6,9.4-16.6,9.4c-3.8,0-7.4-1.1-10.4-3l0.1,0.1c-46.8-26.8-102.8-42.5-162.5-42.5c-32.9,0-64.6,4.8-94.6,13.7l2.3-0.6
|
||||
c-1.7,0.5-3.7,0.9-5.8,0.9c-10.7,0-19.4-8.7-19.4-19.4c0-8.7,5.7-16,13.5-18.5l0.1,0c30.8-9.1,66.2-14.4,102.8-14.4
|
||||
c68.1,0,132,18.2,187,49.9l-1.8-1c5.1,3.3,8.4,8.9,8.4,15.3C407.7,292.5,406.5,296,404.5,298.9L404.5,298.9L404.5,298.9
|
||||
L404.5,298.9z M373.8,369.3c-2.7,4.6-7.6,7.7-13.3,7.7c-3.2,0-6.1-1-8.6-2.6l0.1,0c-40.9-23-89.7-36.5-141.7-36.5
|
||||
c-29.8,0-58.6,4.5-85.7,12.7l2.1-0.5c-1.1,0.3-2.5,0.5-3.8,0.5c-8.7,0-15.8-7.1-15.8-15.8c0-7.4,5.1-13.6,11.9-15.3l0.1,0
|
||||
c27-8,58-12.6,90.1-12.6c58.1,0,112.6,15.1,159.9,41.7l-1.7-0.9c5.2,2.6,8.6,7.8,8.6,13.8C376,364.3,375.2,367.1,373.8,369.3
|
||||
L373.8,369.3L373.8,369.3L373.8,369.3z M256-0.6L256-0.6C114.6-0.6,0,114,0,255.4s114.6,256,256,256s256-114.6,256-256l0,0
|
||||
C511.6,114.2,397.2-0.2,256-0.6L256-0.6L256-0.6L256-0.6z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="st0" d="M406.9,227.2c0,0,0.1,0,0.1,0.1L406.9,227.2z M107.1,198.5c35.8-9.8,73.4-15,112.3-15
|
||||
c68.3,0,132.8,16.1,187.5,43.7c3.8,2.1,8.2,3.3,12.8,3.3c8.2,0,15.6-3.7,20.5-9.6c0,0,0,0,0,0c2-3.6,3.1-7.7,3.1-12
|
||||
c0-9-4.8-16.8-12.1-21.3c-94.7-56.4-244.5-61.5-335.4-34.6l-0.2,0.1c-9.4,3.1-16.3,11.9-16.3,22.4c0,13,10.5,23.5,23.5,23.5
|
||||
c2.5,0,4.9-0.4,7-1.1L107.1,198.5L107.1,198.5z"/>
|
||||
<path class="st1" d="M401.2,274.3c-55-31.8-118.9-49.9-187-49.9c-36.6,0-72,5.3-102.8,14.4l-0.1,0c-7.9,2.5-13.5,9.9-13.5,18.5
|
||||
c0,10.7,8.7,19.4,19.4,19.4c1.3,0,2.5-0.1,3.6-0.3c29.9-8.8,61.5-13.6,94.3-13.6c59.7,0,115.7,15.7,162.4,42.5l0.1,0.1
|
||||
c3,1.9,6.5,3,10.3,3c7,0,13.1-3.7,16.6-9.4l0,0c2-2.9,3.2-6.5,3.2-10.3c0-6.4-3.3-12-8.4-15.3L401.2,274.3L401.2,274.3z"/>
|
||||
<path class="st2" d="M352,374.4C352,374.4,352,374.4,352,374.4L352,374.4z M373.8,369.3C373.8,369.3,373.7,369.4,373.8,369.3
|
||||
L373.8,369.3L373.8,369.3z M367.8,347.8l-0.4-0.2C367.5,347.6,367.6,347.7,367.8,347.8z M369,348.4
|
||||
c-47.3-26.5-101.8-41.7-159.9-41.7c-32.1,0-63.1,4.6-90.1,12.6l-0.1,0c-6.8,1.8-11.9,8-11.9,15.3c0,8.7,7.1,15.8,15.8,15.8
|
||||
c1,0,1.9-0.1,2.8-0.3c26.8-8.1,55.2-12.4,84.6-12.4c52,0,100.8,13.5,141.7,36.5l0,0c2.4,1.6,5.4,2.6,8.5,2.6
|
||||
c5.7,0,10.6-3.1,13.3-7.7h0c1.4-2.3,2.2-5,2.2-7.9c0-5.9-3.3-11-8.2-13.6L369,348.4L369,348.4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-jp" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="jp-a">
|
||||
<path fill-opacity=".7" d="M-88 32h640v480H-88z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g fill-rule="evenodd" stroke-width="1pt" clip-path="url(#jp-a)" transform="translate(88 -32)">
|
||||
<path fill="#fff" d="M-128 32h720v480h-720z"/>
|
||||
<circle cx="523.1" cy="344.1" r="194.9" fill="#bc002d" transform="translate(-168.4 8.6)scale(.76554)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1,251 @@
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
from random import randrange
|
||||
|
||||
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)}"
|
||||
|
||||
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, timeout=30):
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.headers = {
|
||||
'User-Agent': get_random_user_agent()
|
||||
}
|
||||
self.base_api_url = "https://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, 'region': 'auto'}
|
||||
|
||||
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, 'region': 'auto'}
|
||||
|
||||
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:
|
||||
response = self.session.get(download_url, timeout=900)
|
||||
response.raise_for_status()
|
||||
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
while is_paused_callback and is_paused_callback():
|
||||
time.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
with open(temp_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
downloaded_size = len(response.content)
|
||||
total_size = downloaded_size
|
||||
|
||||
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 (Auto) ===")
|
||||
downloader = QobuzDownloader()
|
||||
|
||||
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()
|
||||
@@ -5,6 +5,10 @@ import re
|
||||
from datetime import datetime
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
from random import randrange
|
||||
|
||||
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)}"
|
||||
|
||||
class ProgressCallback:
|
||||
def __call__(self, current, total):
|
||||
@@ -16,16 +20,16 @@ class ProgressCallback:
|
||||
|
||||
class QobuzDownloader:
|
||||
def __init__(self, region="us", timeout=30):
|
||||
if region not in ["eu", "us"]:
|
||||
raise ValueError("Region must be either 'us' or 'eu'")
|
||||
if region not in ["us", "eu", "br", "jp", "au"]:
|
||||
raise ValueError("Region must be one of: 'us', 'eu', 'br', 'jp', 'au'")
|
||||
|
||||
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'
|
||||
'User-Agent': get_random_user_agent()
|
||||
}
|
||||
self.base_api_url = f"https://{region}.qobuz.squid.wtf/api"
|
||||
self.base_api_url = f"https://{region}.qqdl.site/api"
|
||||
self.download_chunk_size = 256 * 1024
|
||||
self.progress_callback = ProgressCallback()
|
||||
|
||||
@@ -124,45 +128,25 @@ class QobuzDownloader:
|
||||
|
||||
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
|
||||
response = self.session.get(download_url, timeout=900)
|
||||
response.raise_for_status()
|
||||
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
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)
|
||||
while is_paused_callback and is_paused_callback():
|
||||
time.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
with open(temp_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
downloaded_size = len(response.content)
|
||||
total_size = downloaded_size
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded_size, total_size)
|
||||
|
||||
os.rename(temp_filename, output_filename)
|
||||
print("Download complete")
|
||||
@@ -243,7 +227,7 @@ class QobuzDownloader:
|
||||
raise Exception(f"Metadata error: {e}")
|
||||
|
||||
def main():
|
||||
print("=== QobuzDL - Qobuz Downloader ===")
|
||||
print("=== QobuzDL - Qobuz Downloader (Region) ===")
|
||||
downloader = QobuzDownloader(region="us")
|
||||
|
||||
isrc = "USAT22409172"
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import httpx
|
||||
import requests
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
|
||||
@@ -35,7 +35,7 @@ class TidalDownloader:
|
||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
||||
|
||||
async def get_access_token(self):
|
||||
def get_access_token(self):
|
||||
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
|
||||
|
||||
payload = {
|
||||
@@ -43,68 +43,67 @@ class TidalDownloader:
|
||||
"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()
|
||||
response = requests.post(
|
||||
url=refresh_url,
|
||||
data=payload,
|
||||
auth=(self.client_id, self.client_secret),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
token_data = response.json()
|
||||
return token_data.get("access_token")
|
||||
else:
|
||||
return None
|
||||
|
||||
except:
|
||||
return None
|
||||
|
||||
def search_tracks(self, query):
|
||||
try:
|
||||
tidal_token = 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
|
||||
}
|
||||
search_data = requests.get(url=search_url, headers=header, timeout=self.timeout)
|
||||
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):
|
||||
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)
|
||||
result = self.search_tracks(query)
|
||||
|
||||
if not result or not result.get("items"):
|
||||
raise Exception(f"No tracks found for query: {query}")
|
||||
@@ -143,99 +142,73 @@ class TidalDownloader:
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting track info: {str(e)}")
|
||||
|
||||
async def get_download_url(self, track_id, quality="LOSSLESS"):
|
||||
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)
|
||||
try:
|
||||
response = requests.get(download_api_url, timeout=self.timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
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)}")
|
||||
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"):
|
||||
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)
|
||||
response = requests.get(art_url, timeout=self.timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
else:
|
||||
print(f"Failed to download album art: HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
response = requests.get(url, timeout=60.0)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"HTTP {response.status_code}")
|
||||
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
while is_paused_callback and is_paused_callback():
|
||||
time.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
with open(temp_filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
downloaded_size = len(response.content)
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded_size, downloaded_size)
|
||||
|
||||
os.rename(temp_filepath, filepath)
|
||||
print("Download complete")
|
||||
return {"success": True, "size": downloaded_size}
|
||||
@@ -252,9 +225,9 @@ class TidalDownloader:
|
||||
|
||||
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)
|
||||
time.sleep(retry_count * 2)
|
||||
|
||||
async def embed_metadata(self, filepath, track_info, search_info=None):
|
||||
def embed_metadata(self, filepath, track_info, search_info=None):
|
||||
try:
|
||||
print("Embedding metadata...")
|
||||
audio = FLAC(filepath)
|
||||
@@ -325,7 +298,7 @@ class TidalDownloader:
|
||||
audio["COMMENT"] = f"Tidal {track_info['audioQuality']}"
|
||||
|
||||
if album_info.get("cover"):
|
||||
album_art = await self.download_album_art(album_info["cover"])
|
||||
album_art = self.download_album_art(album_info["cover"])
|
||||
if album_art:
|
||||
picture = Picture()
|
||||
picture.data = album_art
|
||||
@@ -343,14 +316,14 @@ class TidalDownloader:
|
||||
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):
|
||||
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_info = self.get_track_info(query, isrc)
|
||||
track_id = track_info.get("id")
|
||||
|
||||
if not track_id:
|
||||
@@ -376,12 +349,12 @@ class TidalDownloader:
|
||||
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_info = 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(
|
||||
self.download_file(
|
||||
download_url,
|
||||
output_filename,
|
||||
is_paused_callback=is_paused_callback,
|
||||
@@ -390,7 +363,7 @@ class TidalDownloader:
|
||||
|
||||
print("Adding metadata...")
|
||||
try:
|
||||
await self.embed_metadata(output_filename, download_track_info, track_info)
|
||||
self.embed_metadata(output_filename, download_track_info, track_info)
|
||||
print("Metadata saved")
|
||||
except Exception as e:
|
||||
print(f"Tagging failed: {e}")
|
||||
@@ -398,7 +371,7 @@ class TidalDownloader:
|
||||
print("Done")
|
||||
return output_filename
|
||||
|
||||
async def main():
|
||||
def main():
|
||||
print("=== TidalDL - Tidal Downloader ===")
|
||||
downloader = TidalDownloader(timeout=30, max_retries=3)
|
||||
|
||||
@@ -407,7 +380,7 @@ async def main():
|
||||
output_dir = "."
|
||||
|
||||
try:
|
||||
downloaded_file = await downloader.download(query, isrc, output_dir)
|
||||
downloaded_file = downloader.download(query, isrc, output_dir)
|
||||
print(f"Success: File saved as {downloaded_file}")
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
@@ -425,4 +398,4 @@ if __name__ == "__main__":
|
||||
except:
|
||||
pass
|
||||
|
||||
asyncio.run(main())
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-us" viewBox="0 0 640 480">
|
||||
<path fill="#bd3d44" d="M0 0h640v480H0"/>
|
||||
<path stroke="#fff" stroke-width="37" d="M0 55.3h640M0 129h640M0 203h640M0 277h640M0 351h640M0 425h640"/>
|
||||
<path fill="#192f5d" d="M0 0h364.8v258.5H0"/>
|
||||
<marker id="us-a" markerHeight="30" markerWidth="30">
|
||||
<path fill="#fff" d="m14 0 9 27L0 10h28L5 27z"/>
|
||||
</marker>
|
||||
<path fill="none" marker-mid="url(#us-a)" d="m0 0 16 11h61 61 61 61 60L47 37h61 61 60 61L16 63h61 61 61 61 60L47 89h61 61 60 61L16 115h61 61 61 61 60L47 141h61 61 60 61L16 166h61 61 61 61 60L47 192h61 61 60 61L16 218h61 61 61 61 60z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "3.9"
|
||||
"version": "4.4"
|
||||
}
|
||||
|
||||