Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56a1d29d78 | |||
| 4b7316636e | |||
| 55669ec45f | |||
| 3304b13828 | |||
| 579bb1415a | |||
| f0e71261a5 | |||
| e2ad51da34 | |||
| 9e403ab1ba | |||
| 7058559ddc | |||
| 861f303a4f | |||
| 9a28e8bd94 | |||
| f75385c4e8 | |||
| 2eac274ee0 | |||
| 49a8de1b35 | |||
| cd2500d1df | |||
| ea1372f1fe | |||
| 65fbb9a8e9 | |||
| de16d9e25d | |||
| 6dd19b563b | |||
| 303b76d1ec | |||
| dbcd49225d |
@@ -3,10 +3,10 @@
|
|||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal & Deezer.
|
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal & Deezer.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.1/SpotiFLAC.exe)
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.7/SpotiFLAC.exe)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||

|

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

|
||||||
|
|
||||||
## Lossless Audio Check
|
## Lossless Audio Check
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
+378
-263
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -3,12 +3,16 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from mutagen.flac import FLAC
|
from mutagen.flac import FLAC
|
||||||
|
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:
|
class DeezerDownloader:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
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
|
self.progress_callback = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
+224
-3
@@ -12,8 +12,9 @@ from typing import Dict, Any, List, Tuple
|
|||||||
def get_random_user_agent():
|
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)}"
|
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)}"
|
||||||
|
|
||||||
|
# https://github.com/xyloflake/spot-secrets-go
|
||||||
def generate_totp():
|
def generate_totp():
|
||||||
url = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretBytes.json"
|
url = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, timeout=10)
|
resp = requests.get(url, timeout=10)
|
||||||
@@ -57,8 +58,10 @@ token_url = 'https://open.spotify.com/api/token'
|
|||||||
playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
|
playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
|
||||||
album_base_url = 'https://api.spotify.com/v1/albums/{}'
|
album_base_url = 'https://api.spotify.com/v1/albums/{}'
|
||||||
track_base_url = 'https://api.spotify.com/v1/tracks/{}'
|
track_base_url = 'https://api.spotify.com/v1/tracks/{}'
|
||||||
|
artist_base_url = 'https://api.spotify.com/v1/artists/{}'
|
||||||
|
artist_albums_url = 'https://api.spotify.com/v1/artists/{}/albums'
|
||||||
headers = {
|
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': 'application/json',
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
@@ -97,11 +100,22 @@ def parse_uri(uri):
|
|||||||
if parts[1] == "embed":
|
if parts[1] == "embed":
|
||||||
parts = parts[1:]
|
parts = parts[1:]
|
||||||
|
|
||||||
|
if len(parts) > 1 and parts[1].startswith("intl-"):
|
||||||
|
parts = parts[1:]
|
||||||
|
|
||||||
l = len(parts)
|
l = len(parts)
|
||||||
if l == 3 and parts[1] in ["album", "track", "playlist"]:
|
if l == 3 and parts[1] in ["album", "track", "playlist", "artist"]:
|
||||||
return {"type": parts[1], "id": parts[2]}
|
return {"type": parts[1], "id": parts[2]}
|
||||||
if l == 5 and parts[3] == "playlist":
|
if l == 5 and parts[3] == "playlist":
|
||||||
return {"type": parts[3], "id": parts[4]}
|
return {"type": parts[3], "id": parts[4]}
|
||||||
|
if l >= 4 and parts[1] == "artist" and len(parts) >= 4:
|
||||||
|
if parts[3] == "discography":
|
||||||
|
discography_type = "all"
|
||||||
|
if len(parts) >= 5 and parts[4] in ["all", "album", "single", "compilation"]:
|
||||||
|
discography_type = parts[4]
|
||||||
|
return {"type": "artist_discography", "id": parts[2], "discography_type": discography_type}
|
||||||
|
else:
|
||||||
|
return {"type": "artist", "id": parts[2]}
|
||||||
|
|
||||||
raise SpotifyInvalidUrlException("ERROR: unable to determine Spotify URL type or type is unsupported.")
|
raise SpotifyInvalidUrlException("ERROR: unable to determine Spotify URL type or type is unsupported.")
|
||||||
|
|
||||||
@@ -336,6 +350,69 @@ def get_raw_spotify_data(spotify_url, batch: bool = False, delay: float = 1.0):
|
|||||||
raw_data = track_data
|
raw_data = track_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Failed to get track data: {str(e)}"}
|
return {"error": f"Failed to get track data: {str(e)}"}
|
||||||
|
|
||||||
|
elif url_info["type"] == "artist_discography":
|
||||||
|
try:
|
||||||
|
artist_data = get_json_from_api(
|
||||||
|
artist_base_url.format(url_info["id"]),
|
||||||
|
access_token
|
||||||
|
)
|
||||||
|
if not artist_data:
|
||||||
|
return {"error": "Failed to get artist data"}
|
||||||
|
|
||||||
|
discography_type = url_info.get("discography_type", "all")
|
||||||
|
if discography_type == "all":
|
||||||
|
include_groups = "album,single,compilation"
|
||||||
|
else:
|
||||||
|
include_groups = discography_type
|
||||||
|
|
||||||
|
albums = []
|
||||||
|
albums_url = f'{artist_albums_url.format(url_info["id"])}?include_groups={include_groups}&limit=50'
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
albums, num_batches = fetch_tracks_in_batches(albums_url, access_token, 50, delay)
|
||||||
|
raw_data = {
|
||||||
|
"artist_info": artist_data,
|
||||||
|
"albums": albums,
|
||||||
|
"discography_type": discography_type,
|
||||||
|
"_batch_count": num_batches,
|
||||||
|
"_batch_enabled": True
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
while albums_url:
|
||||||
|
album_data = get_json_from_api(albums_url, access_token)
|
||||||
|
if not album_data:
|
||||||
|
break
|
||||||
|
|
||||||
|
albums.extend(album_data['items'])
|
||||||
|
albums_url = album_data.get('next')
|
||||||
|
if albums_url and "&locale=" in albums_url:
|
||||||
|
albums_url = albums_url.split("&locale=")[0]
|
||||||
|
|
||||||
|
raw_data = {
|
||||||
|
"artist_info": artist_data,
|
||||||
|
"albums": albums,
|
||||||
|
"discography_type": discography_type,
|
||||||
|
"_batch_enabled": False
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_data['_token'] = access_token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to get artist discography data: {str(e)}"}
|
||||||
|
|
||||||
|
elif url_info["type"] == "artist":
|
||||||
|
try:
|
||||||
|
artist_data = get_json_from_api(
|
||||||
|
artist_base_url.format(url_info["id"]),
|
||||||
|
access_token
|
||||||
|
)
|
||||||
|
if not artist_data:
|
||||||
|
return {"error": "Failed to get artist data"}
|
||||||
|
|
||||||
|
raw_data = artist_data
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to get artist data: {str(e)}"}
|
||||||
|
|
||||||
return raw_data
|
return raw_data
|
||||||
|
|
||||||
@@ -466,6 +543,134 @@ def format_playlist_data(playlist_data):
|
|||||||
"track_list": track_list
|
"track_list": track_list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def format_artist_discography_data(discography_data):
|
||||||
|
artist_info = discography_data.get('artist_info', {})
|
||||||
|
albums = discography_data.get('albums', [])
|
||||||
|
access_token = discography_data.get('_token', '')
|
||||||
|
|
||||||
|
artist_image = ''
|
||||||
|
if artist_info.get('images'):
|
||||||
|
artist_image = artist_info.get('images', [{}])[0].get('url', '')
|
||||||
|
|
||||||
|
formatted_artist_info = {
|
||||||
|
"name": artist_info.get('name', ''),
|
||||||
|
"followers": artist_info.get('followers', {}).get('total', 0),
|
||||||
|
"genres": artist_info.get('genres', []),
|
||||||
|
"images": artist_image,
|
||||||
|
"external_urls": artist_info.get('external_urls', {}).get('spotify', ''),
|
||||||
|
"discography_type": discography_data.get('discography_type', 'all'),
|
||||||
|
"total_albums": len(albums)
|
||||||
|
}
|
||||||
|
|
||||||
|
if discography_data.get('_batch_enabled', False):
|
||||||
|
formatted_artist_info["batch"] = f"{discography_data.get('_batch_count', 1)}"
|
||||||
|
|
||||||
|
album_list = []
|
||||||
|
all_tracks = []
|
||||||
|
|
||||||
|
for album in albums:
|
||||||
|
album_image = ''
|
||||||
|
if album.get('images'):
|
||||||
|
album_image = album.get('images', [{}])[0].get('url', '')
|
||||||
|
|
||||||
|
album_artists = []
|
||||||
|
for artist in album.get('artists', []):
|
||||||
|
album_artists.append(artist['name'])
|
||||||
|
|
||||||
|
album_info = {
|
||||||
|
"id": album.get('id', ''),
|
||||||
|
"name": album.get('name', ''),
|
||||||
|
"album_type": album.get('album_type', ''),
|
||||||
|
"release_date": album.get('release_date', ''),
|
||||||
|
"total_tracks": album.get('total_tracks', 0),
|
||||||
|
"artists": ", ".join(album_artists),
|
||||||
|
"images": album_image,
|
||||||
|
"external_urls": album.get('external_urls', {}).get('spotify', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
album_list.append(album_info)
|
||||||
|
|
||||||
|
if access_token and album.get('id'):
|
||||||
|
try:
|
||||||
|
album_tracks_data = get_json_from_api(
|
||||||
|
f'{album_base_url.format(album.get("id"))}/tracks?limit=50',
|
||||||
|
access_token
|
||||||
|
)
|
||||||
|
|
||||||
|
if album_tracks_data:
|
||||||
|
tracks = []
|
||||||
|
tracks_url = f'{album_base_url.format(album.get("id"))}/tracks?limit=50'
|
||||||
|
|
||||||
|
while tracks_url:
|
||||||
|
track_data = get_json_from_api(tracks_url, access_token)
|
||||||
|
if not track_data:
|
||||||
|
break
|
||||||
|
|
||||||
|
tracks.extend(track_data['items'])
|
||||||
|
tracks_url = track_data.get('next')
|
||||||
|
if tracks_url and "&locale=" in tracks_url:
|
||||||
|
tracks_url = tracks_url.split("&locale=")[0]
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
track_artists = []
|
||||||
|
for artist in track.get('artists', []):
|
||||||
|
track_artists.append(artist['name'])
|
||||||
|
|
||||||
|
track_id = track.get('id', '')
|
||||||
|
track_isrc = ''
|
||||||
|
|
||||||
|
if track_id:
|
||||||
|
try:
|
||||||
|
full_track_data = get_json_from_api(
|
||||||
|
track_base_url.format(track_id),
|
||||||
|
access_token
|
||||||
|
)
|
||||||
|
if full_track_data:
|
||||||
|
track_isrc = full_track_data.get('external_ids', {}).get('isrc', '')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
formatted_track = {
|
||||||
|
"artists": ", ".join(track_artists),
|
||||||
|
"name": track.get('name', ''),
|
||||||
|
"album_name": album.get('name', ''),
|
||||||
|
"album_type": album.get('album_type', ''),
|
||||||
|
"duration_ms": track.get('duration_ms', 0),
|
||||||
|
"images": album_image,
|
||||||
|
"release_date": album.get('release_date', ''),
|
||||||
|
"track_number": track.get('track_number', 0),
|
||||||
|
"external_urls": track.get('external_urls', {}).get('spotify', ''),
|
||||||
|
"isrc": track_isrc
|
||||||
|
}
|
||||||
|
|
||||||
|
all_tracks.append(formatted_track)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting tracks for album {album.get('name', '')}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
"artist_info": formatted_artist_info,
|
||||||
|
"album_list": album_list,
|
||||||
|
"track_list": all_tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
def format_artist_data(artist_data):
|
||||||
|
artist_image = ''
|
||||||
|
if artist_data.get('images'):
|
||||||
|
artist_image = artist_data.get('images', [{}])[0].get('url', '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"artist": {
|
||||||
|
"name": artist_data.get('name', ''),
|
||||||
|
"followers": artist_data.get('followers', {}).get('total', 0),
|
||||||
|
"genres": artist_data.get('genres', []),
|
||||||
|
"images": artist_image,
|
||||||
|
"external_urls": artist_data.get('external_urls', {}).get('spotify', ''),
|
||||||
|
"popularity": artist_data.get('popularity', 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def process_spotify_data(raw_data, data_type):
|
def process_spotify_data(raw_data, data_type):
|
||||||
if not raw_data or "error" in raw_data:
|
if not raw_data or "error" in raw_data:
|
||||||
return {"error": "Invalid data provided"}
|
return {"error": "Invalid data provided"}
|
||||||
@@ -477,6 +682,10 @@ def process_spotify_data(raw_data, data_type):
|
|||||||
return format_album_data(raw_data)
|
return format_album_data(raw_data)
|
||||||
elif data_type == "playlist":
|
elif data_type == "playlist":
|
||||||
return format_playlist_data(raw_data)
|
return format_playlist_data(raw_data)
|
||||||
|
elif data_type == "artist_discography":
|
||||||
|
return format_artist_discography_data(raw_data)
|
||||||
|
elif data_type == "artist":
|
||||||
|
return format_artist_data(raw_data)
|
||||||
else:
|
else:
|
||||||
return {"error": "Invalid data type"}
|
return {"error": "Invalid data type"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -495,11 +704,23 @@ if __name__ == '__main__':
|
|||||||
album = "https://open.spotify.com/album/6J84szYCnMfzEcvIcfWMFL"
|
album = "https://open.spotify.com/album/6J84szYCnMfzEcvIcfWMFL"
|
||||||
song = "https://open.spotify.com/track/7so0lgd0zP2Sbgs2d7a1SZ"
|
song = "https://open.spotify.com/track/7so0lgd0zP2Sbgs2d7a1SZ"
|
||||||
|
|
||||||
|
artist_discography_all = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/all"
|
||||||
|
artist_discography_albums = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/album"
|
||||||
|
artist_discography_singles = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/single"
|
||||||
|
artist_discography_compilations = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/compilation"
|
||||||
|
|
||||||
|
print("=== Testing Artist Discography (All) ===")
|
||||||
|
filtered_discography = get_filtered_data(artist_discography_all, batch=True, delay=0.1)
|
||||||
|
print(json.dumps(filtered_discography, indent=2))
|
||||||
|
|
||||||
|
print("\n=== Testing Playlist ===")
|
||||||
filtered_playlist = get_filtered_data(playlist, batch=True, delay=0.1)
|
filtered_playlist = get_filtered_data(playlist, batch=True, delay=0.1)
|
||||||
print(json.dumps(filtered_playlist, indent=2))
|
print(json.dumps(filtered_playlist, indent=2))
|
||||||
|
|
||||||
|
print("\n=== Testing Album ===")
|
||||||
filtered_album = get_filtered_data(album)
|
filtered_album = get_filtered_data(album)
|
||||||
print(json.dumps(filtered_album, indent=2))
|
print(json.dumps(filtered_album, indent=2))
|
||||||
|
|
||||||
|
print("\n=== Testing Track ===")
|
||||||
filtered_track = get_filtered_data(song)
|
filtered_track = get_filtered_data(song)
|
||||||
print(json.dumps(filtered_track, indent=2))
|
print(json.dumps(filtered_track, indent=2))
|
||||||
-251
@@ -1,251 +0,0 @@
|
|||||||
import requests
|
|
||||||
import time
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from mutagen.flac import FLAC, Picture
|
|
||||||
from mutagen.id3 import PictureType
|
|
||||||
|
|
||||||
class ProgressCallback:
|
|
||||||
def __call__(self, current, total):
|
|
||||||
if total > 0:
|
|
||||||
percent = (current / total) * 100
|
|
||||||
print(f"\r{percent:.2f}% ({current}/{total})", end="")
|
|
||||||
else:
|
|
||||||
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
|
||||||
|
|
||||||
class QobuzDownloader:
|
|
||||||
def __init__(self, region="us", timeout=30):
|
|
||||||
if region not in ["eu", "us"]:
|
|
||||||
raise ValueError("Region must be either 'us' or 'eu'")
|
|
||||||
|
|
||||||
self.region = region
|
|
||||||
self.timeout = timeout
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
|
||||||
}
|
|
||||||
self.base_api_url = f"https://{region}.qobuz.squid.wtf/api"
|
|
||||||
self.download_chunk_size = 256 * 1024
|
|
||||||
self.progress_callback = ProgressCallback()
|
|
||||||
|
|
||||||
def set_progress_callback(self, callback):
|
|
||||||
self.progress_callback = callback
|
|
||||||
|
|
||||||
def sanitize_filename(self, filename):
|
|
||||||
if not filename:
|
|
||||||
return "Unknown Track"
|
|
||||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
|
||||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
|
||||||
|
|
||||||
def get_track_info(self, isrc):
|
|
||||||
print(f"Fetching: {isrc}")
|
|
||||||
search_url = f"{self.base_api_url}/get-music"
|
|
||||||
params = {'q': isrc, 'offset': 0, 'limit': 10}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.session.get(search_url, params=params, timeout=self.timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
selected_track = None
|
|
||||||
if data and data.get("success"):
|
|
||||||
items = data.get("data", {}).get("tracks", {}).get("items", [])
|
|
||||||
priority = {24: 1, 16: 2}
|
|
||||||
for track in items:
|
|
||||||
if track.get("isrc") == isrc:
|
|
||||||
current_prio = priority.get(track.get("maximum_bit_depth"), 3)
|
|
||||||
if selected_track is None or current_prio < priority.get(selected_track.get("maximum_bit_depth"), 3):
|
|
||||||
selected_track = track
|
|
||||||
if current_prio == 1:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not selected_track:
|
|
||||||
raise Exception(f"Track not found: {isrc}")
|
|
||||||
|
|
||||||
title = selected_track.get('title', 'Unknown')
|
|
||||||
bit_depth = selected_track.get('maximum_bit_depth', 'Unknown')
|
|
||||||
print(f"Found: {title} ({bit_depth}b)")
|
|
||||||
return selected_track
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
raise Exception(f"Request error: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error: {e}")
|
|
||||||
|
|
||||||
def get_download_url(self, track_id):
|
|
||||||
print("Fetching URL...")
|
|
||||||
download_api_url = f"{self.base_api_url}/download-music"
|
|
||||||
params = {'track_id': track_id, 'quality': 27}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.session.get(download_api_url, params=params, timeout=self.timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if data and data.get("success") and data.get("data", {}).get("url"):
|
|
||||||
download_url = data["data"]["url"]
|
|
||||||
print("URL found")
|
|
||||||
return download_url
|
|
||||||
else:
|
|
||||||
error_msg = data.get('error', {}).get('message', 'Unknown API error')
|
|
||||||
raise Exception(f"API error: {error_msg}")
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
raise Exception(f"Request error: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error: {e}")
|
|
||||||
|
|
||||||
def download(self, isrc, output_dir=".", is_paused_callback=None, is_stopped_callback=None):
|
|
||||||
if output_dir != ".":
|
|
||||||
try:
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
except OSError as e:
|
|
||||||
raise Exception(f"Directory error: {e}")
|
|
||||||
|
|
||||||
track_info = self.get_track_info(isrc)
|
|
||||||
track_id = track_info.get("id")
|
|
||||||
|
|
||||||
if not track_id:
|
|
||||||
raise Exception("No track ID found")
|
|
||||||
|
|
||||||
artist_name = self.sanitize_filename(track_info.get('performer', {}).get('name'))
|
|
||||||
track_title = self.sanitize_filename(track_info.get('title'))
|
|
||||||
output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
|
|
||||||
|
|
||||||
if os.path.exists(output_filename):
|
|
||||||
file_size = os.path.getsize(output_filename)
|
|
||||||
if file_size > 0:
|
|
||||||
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
|
||||||
return output_filename
|
|
||||||
|
|
||||||
download_url = self.get_download_url(track_id)
|
|
||||||
temp_filename = output_filename + ".part"
|
|
||||||
|
|
||||||
print(f"Downloading...")
|
|
||||||
try:
|
|
||||||
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 ===")
|
|
||||||
downloader = QobuzDownloader(region="us")
|
|
||||||
|
|
||||||
isrc = "USAT22409172"
|
|
||||||
output_dir = "."
|
|
||||||
|
|
||||||
try:
|
|
||||||
downloaded_file = downloader.download(isrc, output_dir)
|
|
||||||
print(f"Success: File saved as {downloaded_file}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {str(e)}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
import sys
|
|
||||||
if sys.platform == "win32":
|
|
||||||
import os
|
|
||||||
os.system("chcp 65001 > nul")
|
|
||||||
try:
|
|
||||||
sys.stdout.reconfigure(encoding='utf-8')
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
PyQt6
|
||||||
|
pyqt6-tools
|
||||||
|
pyqtdarktheme
|
||||||
|
requests
|
||||||
|
mutagen
|
||||||
|
pyotp
|
||||||
|
packaging
|
||||||
|
pyinstaller
|
||||||
+1
-1
@@ -144,7 +144,7 @@ class TidalDownloader:
|
|||||||
|
|
||||||
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://tidal.401658.xyz/track/?id={track_id}&quality={quality}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(download_api_url, timeout=self.timeout)
|
response = requests.get(download_api_url, timeout=self.timeout)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 648 B |
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "4.1"
|
"version": "4.7"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user