Compare commits

..

13 Commits

Author SHA1 Message Date
afkarxyz bdc7717ef3 v4.2 2025-07-26 09:37:45 +07:00
afkarxyz 9a7c539418 Update README.md 2025-07-26 08:39:19 +07:00
afkarxyz 888ce2b61c v4.1 2025-07-24 06:35:18 +07:00
afkarxyz e2e1ab1cfa v4.1 2025-07-24 06:34:25 +07:00
afkarxyz 9bddeab0d1 v4.1 2025-07-24 06:14:42 +07:00
afkarxyz 03a30ee09a v4.0 2025-07-22 14:03:41 +07:00
afkarxyz 2d908e2f75 v4.0 2025-07-22 08:02:54 +07:00
afkarxyz e8f7bf7313 v4.0 2025-07-22 07:59:28 +07:00
afkarxyz 1f0922f358 v4.0 2025-07-22 07:54:03 +07:00
afkarxyz 3f267a3fa1 v3.9.5 2025-07-22 07:42:53 +07:00
afkarxyz 22da74a027 v3.9 2025-07-21 17:33:39 +07:00
afkarxyz 783350fe88 v3.9 2025-07-21 17:28:44 +07:00
afkarxyz 0057d43f46 v3.8 2025-07-14 13:20:14 +07:00
13 changed files with 985 additions and 372 deletions
+5 -7
View File
@@ -3,20 +3,18 @@
![SpotiFLAC](https://github.com/user-attachments/assets/b4c4f403-edbd-4a71-b74b-c7d433d47d06)
<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.
</div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v3.7/SpotiFLAC.exe)
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.1/SpotiFLAC.exe)
## Screenshots
![image](https://github.com/user-attachments/assets/70a5dceb-3374-4255-8f6a-4afb5ee534b0)
![image](https://github.com/user-attachments/assets/180b8322-ce2d-4842-a5dd-ac4d7b7a5efa)
![image](https://github.com/user-attachments/assets/9f0d6aa5-456b-4a90-b48a-7e0c22819ebd)
![image](https://github.com/user-attachments/assets/3f84d53b-2da1-4488-986c-772b82832f2d)
![image](https://github.com/user-attachments/assets/be9a79b5-260e-4948-9f72-10c735210ab7)
![image](https://github.com/user-attachments/assets/1feec621-f8bf-4b2a-ae73-afcb1fb1deba)
![image](https://github.com/user-attachments/assets/f604dc04-4ee6-4084-b314-0be7cd5d7ef9)
## Lossless Audio Check
+517 -151
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+237
View File
@@ -0,0 +1,237 @@
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)
response.raise_for_status()
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)
with open(file_path, 'wb') as f:
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}")
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():
print("=== DeezerDL - Deezer Downloader ===")
downloader = DeezerDownloader()
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())
+28
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+48
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+14 -34
View File
@@ -124,45 +124,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()
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")
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():
f.close()
if os.path.exists(temp_filename):
os.remove(temp_filename)
raise Exception("Download stopped")
f.write(chunk)
downloaded_size += len(chunk)
while is_paused_callback and is_paused_callback():
time.sleep(0.1)
if is_stopped_callback and is_stopped_callback():
raise Exception("Download stopped")
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")
with open(temp_filename, 'wb') as f:
f.write(response.content)
last_update_time = current_time
downloaded_size = len(response.content)
total_size = downloaded_size
if self.progress_callback:
self.progress_callback(downloaded_size, total_size)
if self.progress_callback:
self.progress_callback(downloaded_size, total_size)
os.rename(temp_filename, output_filename)
print("Download complete")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+95 -148
View File
@@ -2,9 +2,8 @@ import asyncio
import json
import os
import re
import tempfile
import time
import httpx
import requests
from mutagen.flac import FLAC, Picture
from mutagen.id3 import PictureType
@@ -24,22 +23,11 @@ class TidalDownloader:
self.progress_callback = ProgressCallback()
self.client_id = "zU4XHVVkc2tDPo4t"
self.client_secret = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4="
self.temp_dir = tempfile.gettempdir()
self.token_path = os.path.join(self.temp_dir, "tidal_token.json")
self.access_token = None
self._load_token()
def set_progress_callback(self, callback):
self.progress_callback = callback
def _load_token(self):
if os.path.exists(self.token_path):
try:
with open(self.token_path, "r") as tok:
token = json.loads(tok.read())
self.access_token = token.get("access_token")
except:
pass
def sanitize_filename(self, filename):
if not filename:
@@ -47,10 +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):
if self.access_token:
return self.access_token
def get_access_token(self):
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
payload = {
@@ -58,79 +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),
)
try:
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()
new_token = token_data.get("access_token")
try:
with open(self.token_path, "w") as f:
json.dump({
"access_token": new_token
}, f)
except:
pass
self.access_token = new_token
return new_token
else:
return None
except:
if response.status_code == 200:
token_data = response.json()
return token_data.get("access_token")
else:
return None
async def search_tracks(self, query):
except:
return None
def search_tracks(self, query):
try:
tidal_token = await self.get_access_token()
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()
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", [])]
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
}
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}")
@@ -169,98 +142,72 @@ 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 {}
}
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}")
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)}")
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}")
response = requests.get(url, timeout=60.0)
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
if is_stopped_callback and is_stopped_callback():
raise Exception("Download stopped")
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():
time.sleep(0.1)
if is_stopped_callback and is_stopped_callback():
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")
with open(temp_filepath, 'wb') as f:
f.write(response.content)
f.write(chunk)
downloaded_size += len(chunk)
downloaded_size = len(response.content)
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)
if self.progress_callback:
self.progress_callback(downloaded_size, downloaded_size)
os.rename(temp_filepath, filepath)
print("Download complete")
@@ -278,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)
@@ -351,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
@@ -369,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:
@@ -402,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,
@@ -416,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}")
@@ -424,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)
@@ -433,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)}")
@@ -451,4 +398,4 @@ if __name__ == "__main__":
except:
pass
asyncio.run(main())
main()
+9
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
"version": "3.7"
"version": "4.1"
}