Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdc7717ef3 | |||
| 9a7c539418 | |||
| 888ce2b61c | |||
| e2e1ab1cfa | |||
| 9bddeab0d1 | |||
| 03a30ee09a | |||
| 2d908e2f75 | |||
| e8f7bf7313 | |||
| 1f0922f358 | |||
| 3f267a3fa1 | |||
| 22da74a027 | |||
| 783350fe88 | |||
| 0057d43f46 |
@@ -3,20 +3,18 @@
|
|||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<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>
|
</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
|
## Screenshots
|
||||||
|
|
||||||

|

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

|

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

|

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

|
|
||||||
|
|
||||||
## Lossless Audio Check
|
## Lossless Audio Check
|
||||||
|
|
||||||
|
|||||||
+506
-140
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
+237
@@ -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())
|
||||||
@@ -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 |
@@ -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 |
+5
-25
@@ -124,42 +124,22 @@ class QobuzDownloader:
|
|||||||
|
|
||||||
print(f"Downloading...")
|
print(f"Downloading...")
|
||||||
try:
|
try:
|
||||||
with self.session.get(download_url, stream=True, timeout=900) as response, \
|
response = self.session.get(download_url, timeout=900)
|
||||||
open(temp_filename, 'wb') as f:
|
|
||||||
response.raise_for_status()
|
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():
|
if is_stopped_callback and is_stopped_callback():
|
||||||
f.close()
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
raise Exception("Download stopped")
|
raise Exception("Download stopped")
|
||||||
|
|
||||||
while is_paused_callback and is_paused_callback():
|
while is_paused_callback and is_paused_callback():
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
if is_stopped_callback and is_stopped_callback():
|
if is_stopped_callback and is_stopped_callback():
|
||||||
f.close()
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
raise Exception("Download stopped")
|
raise Exception("Download stopped")
|
||||||
f.write(chunk)
|
|
||||||
downloaded_size += len(chunk)
|
|
||||||
|
|
||||||
current_time = time.time()
|
with open(temp_filename, 'wb') as f:
|
||||||
if current_time - last_update_time >= 1:
|
f.write(response.content)
|
||||||
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
|
downloaded_size = len(response.content)
|
||||||
|
total_size = downloaded_size
|
||||||
|
|
||||||
if self.progress_callback:
|
if self.progress_callback:
|
||||||
self.progress_callback(downloaded_size, total_size)
|
self.progress_callback(downloaded_size, total_size)
|
||||||
|
|||||||
+33
-86
@@ -2,9 +2,8 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
import httpx
|
import requests
|
||||||
from mutagen.flac import FLAC, Picture
|
from mutagen.flac import FLAC, Picture
|
||||||
from mutagen.id3 import PictureType
|
from mutagen.id3 import PictureType
|
||||||
|
|
||||||
@@ -24,22 +23,11 @@ class TidalDownloader:
|
|||||||
self.progress_callback = ProgressCallback()
|
self.progress_callback = ProgressCallback()
|
||||||
self.client_id = "zU4XHVVkc2tDPo4t"
|
self.client_id = "zU4XHVVkc2tDPo4t"
|
||||||
self.client_secret = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4="
|
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):
|
def set_progress_callback(self, callback):
|
||||||
self.progress_callback = 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):
|
def sanitize_filename(self, filename):
|
||||||
if not filename:
|
if not filename:
|
||||||
@@ -47,10 +35,7 @@ class TidalDownloader:
|
|||||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
||||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
||||||
|
|
||||||
async def get_access_token(self):
|
def get_access_token(self):
|
||||||
if self.access_token:
|
|
||||||
return self.access_token
|
|
||||||
|
|
||||||
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
|
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -58,45 +43,33 @@ class TidalDownloader:
|
|||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(http2=True) as client:
|
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = requests.post(
|
||||||
url=refresh_url,
|
url=refresh_url,
|
||||||
data=payload,
|
data=payload,
|
||||||
auth=(self.client_id, self.client_secret),
|
auth=(self.client_id, self.client_secret),
|
||||||
|
timeout=self.timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
token_data = response.json()
|
token_data = response.json()
|
||||||
new_token = token_data.get("access_token")
|
return 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:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except:
|
except:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def search_tracks(self, query):
|
def search_tracks(self, query):
|
||||||
try:
|
try:
|
||||||
tidal_token = await self.get_access_token()
|
tidal_token = self.get_access_token()
|
||||||
if not tidal_token:
|
if not tidal_token:
|
||||||
raise Exception("Failed to get access 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"
|
search_url = f"https://api.tidal.com/v1/search/tracks?query={query}&limit=25&offset=0&countryCode=US"
|
||||||
header = {"authorization": f"Bearer {tidal_token}"}
|
header = {"authorization": f"Bearer {tidal_token}"}
|
||||||
|
|
||||||
async with httpx.AsyncClient(http2=True) as client:
|
search_data = requests.get(url=search_url, headers=header, timeout=self.timeout)
|
||||||
search_data = await client.get(url=search_url, headers=header)
|
|
||||||
response_data = search_data.json()
|
response_data = search_data.json()
|
||||||
|
|
||||||
filtered_items = [{
|
filtered_items = [{
|
||||||
@@ -126,11 +99,11 @@ class TidalDownloader:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Search error: {str(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 ""))
|
print(f"Fetching: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await self.search_tracks(query)
|
result = self.search_tracks(query)
|
||||||
|
|
||||||
if not result or not result.get("items"):
|
if not result or not result.get("items"):
|
||||||
raise Exception(f"No tracks found for query: {query}")
|
raise Exception(f"No tracks found for query: {query}")
|
||||||
@@ -169,13 +142,12 @@ class TidalDownloader:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error getting track info: {str(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...")
|
print("Fetching URL...")
|
||||||
download_api_url = f"https://hifi.401658.xyz/track/?id={track_id}&quality={quality}"
|
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:
|
try:
|
||||||
response = await client.get(download_api_url)
|
response = requests.get(download_api_url, timeout=self.timeout)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -195,12 +167,11 @@ class TidalDownloader:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error getting download URL: {str(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:
|
try:
|
||||||
art_url = f"https://resources.tidal.com/images/{album_id.replace('-', '/')}/{size}.jpg"
|
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 = requests.get(art_url, timeout=self.timeout)
|
||||||
response = await client.get(art_url)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return response.content
|
return response.content
|
||||||
@@ -212,55 +183,31 @@ class TidalDownloader:
|
|||||||
print(f"Error downloading album art: {str(e)}")
|
print(f"Error downloading album art: {str(e)}")
|
||||||
return None
|
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"
|
temp_filepath = filepath + ".part"
|
||||||
retry_count = 0
|
retry_count = 0
|
||||||
|
|
||||||
while retry_count <= self.max_retries:
|
while retry_count <= self.max_retries:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(http2=True, timeout=60.0) as client:
|
response = requests.get(url, timeout=60.0)
|
||||||
async with client.stream('GET', url) as response:
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise Exception(f"HTTP {response.status_code}")
|
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():
|
if is_stopped_callback and is_stopped_callback():
|
||||||
f.close()
|
|
||||||
if os.path.exists(temp_filepath):
|
|
||||||
os.remove(temp_filepath)
|
|
||||||
raise Exception("Download stopped")
|
raise Exception("Download stopped")
|
||||||
|
|
||||||
while is_paused_callback and is_paused_callback():
|
while is_paused_callback and is_paused_callback():
|
||||||
await asyncio.sleep(0.1)
|
time.sleep(0.1)
|
||||||
if is_stopped_callback and is_stopped_callback():
|
if is_stopped_callback and is_stopped_callback():
|
||||||
f.close()
|
|
||||||
if os.path.exists(temp_filepath):
|
|
||||||
os.remove(temp_filepath)
|
|
||||||
raise Exception("Download stopped")
|
raise Exception("Download stopped")
|
||||||
|
|
||||||
f.write(chunk)
|
with open(temp_filepath, 'wb') as f:
|
||||||
downloaded_size += len(chunk)
|
f.write(response.content)
|
||||||
|
|
||||||
current_time = time.time()
|
downloaded_size = len(response.content)
|
||||||
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:
|
if self.progress_callback:
|
||||||
self.progress_callback(downloaded_size, total_size)
|
self.progress_callback(downloaded_size, downloaded_size)
|
||||||
|
|
||||||
os.rename(temp_filepath, filepath)
|
os.rename(temp_filepath, filepath)
|
||||||
print("Download complete")
|
print("Download complete")
|
||||||
@@ -278,9 +225,9 @@ class TidalDownloader:
|
|||||||
|
|
||||||
print(f"Download error (attempt {retry_count}/{self.max_retries}): {str(e)}")
|
print(f"Download error (attempt {retry_count}/{self.max_retries}): {str(e)}")
|
||||||
print(f"Retrying in {retry_count * 2} seconds...")
|
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:
|
try:
|
||||||
print("Embedding metadata...")
|
print("Embedding metadata...")
|
||||||
audio = FLAC(filepath)
|
audio = FLAC(filepath)
|
||||||
@@ -351,7 +298,7 @@ class TidalDownloader:
|
|||||||
audio["COMMENT"] = f"Tidal {track_info['audioQuality']}"
|
audio["COMMENT"] = f"Tidal {track_info['audioQuality']}"
|
||||||
|
|
||||||
if album_info.get("cover"):
|
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:
|
if album_art:
|
||||||
picture = Picture()
|
picture = Picture()
|
||||||
picture.data = album_art
|
picture.data = album_art
|
||||||
@@ -369,14 +316,14 @@ class TidalDownloader:
|
|||||||
print(f"Error embedding metadata: {str(e)}")
|
print(f"Error embedding metadata: {str(e)}")
|
||||||
return False
|
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 != ".":
|
if output_dir != ".":
|
||||||
try:
|
try:
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise Exception(f"Directory error: {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")
|
track_id = track_info.get("id")
|
||||||
|
|
||||||
if not track_id:
|
if not track_id:
|
||||||
@@ -402,12 +349,12 @@ class TidalDownloader:
|
|||||||
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
||||||
return output_filename
|
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_url = download_info["download_url"]
|
||||||
download_track_info = download_info["track_info"]
|
download_track_info = download_info["track_info"]
|
||||||
|
|
||||||
print(f"Downloading to: {output_filename}")
|
print(f"Downloading to: {output_filename}")
|
||||||
await self.download_file(
|
self.download_file(
|
||||||
download_url,
|
download_url,
|
||||||
output_filename,
|
output_filename,
|
||||||
is_paused_callback=is_paused_callback,
|
is_paused_callback=is_paused_callback,
|
||||||
@@ -416,7 +363,7 @@ class TidalDownloader:
|
|||||||
|
|
||||||
print("Adding metadata...")
|
print("Adding metadata...")
|
||||||
try:
|
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")
|
print("Metadata saved")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Tagging failed: {e}")
|
print(f"Tagging failed: {e}")
|
||||||
@@ -424,7 +371,7 @@ class TidalDownloader:
|
|||||||
print("Done")
|
print("Done")
|
||||||
return output_filename
|
return output_filename
|
||||||
|
|
||||||
async def main():
|
def main():
|
||||||
print("=== TidalDL - Tidal Downloader ===")
|
print("=== TidalDL - Tidal Downloader ===")
|
||||||
downloader = TidalDownloader(timeout=30, max_retries=3)
|
downloader = TidalDownloader(timeout=30, max_retries=3)
|
||||||
|
|
||||||
@@ -433,7 +380,7 @@ async def main():
|
|||||||
output_dir = "."
|
output_dir = "."
|
||||||
|
|
||||||
try:
|
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}")
|
print(f"Success: File saved as {downloaded_file}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error: {str(e)}")
|
print(f"Error: {str(e)}")
|
||||||
@@ -451,4 +398,4 @@ if __name__ == "__main__":
|
|||||||
except:
|
except:
|
||||||
pass
|
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
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "3.7"
|
"version": "4.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user