Compare commits

...

12 Commits

Author SHA1 Message Date
afkarxyz de798e4807 v3.4 2025-06-23 11:52:19 +07:00
afkarxyz 0e7ba6d029 v3.3 2025-06-21 05:09:44 +07:00
afkarxyz 2306b1f8d2 v3.3 2025-06-21 05:03:32 +07:00
afkarxyz 1b0d67702d v3.2 2025-06-11 05:11:58 +07:00
afkarxyz 00e369677f v3.2 2025-06-11 05:07:20 +07:00
afkarxyz c3e1607ca6 v3.1 2025-06-02 13:13:10 +07:00
afkarxyz 59428e7679 v3.1 2025-06-02 13:09:18 +07:00
afkarxyz 33c4698286 v3.0 2025-05-31 19:37:36 +07:00
afkarxyz 3ac4c34d73 v3.0 2025-05-31 19:33:16 +07:00
afkarxyz 88e303cbe4 v2.9 2025-05-30 22:28:24 +07:00
afkarxyz c13855fadd v2.9 2025-05-30 22:22:14 +07:00
afkarxyz 2b12684960 v2.8 2025-05-23 16:47:38 +07:00
5 changed files with 74 additions and 56 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal, Amazon Music, and Deezer <code>(via Lucida)</code>, as well as Qobuz <code>(via SquidWTF)</code>. <b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal, Amazon Music, and Deezer <code>(via Lucida)</code>, as well as Qobuz <code>(via SquidWTF)</code>.
</div> </div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v2.7/SpotiFLAC.exe) ### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v3.3/SpotiFLAC.exe)
# #
+32 -38
View File
@@ -56,7 +56,7 @@ class DownloadWorker(QThread):
def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False, def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False,
album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True, album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True,
use_album_subfolders=False, use_fallback=False, service="amazon", timeout=30, qobuz_region="us"): use_album_subfolders=False, service="amazon", timeout=30, qobuz_region="us"):
super().__init__() super().__init__()
self.tracks = tracks self.tracks = tracks
self.outpath = outpath self.outpath = outpath
@@ -67,7 +67,6 @@ class DownloadWorker(QThread):
self.filename_format = filename_format self.filename_format = filename_format
self.use_track_numbers = use_track_numbers self.use_track_numbers = use_track_numbers
self.use_album_subfolders = use_album_subfolders self.use_album_subfolders = use_album_subfolders
self.use_fallback = use_fallback
self.service = service self.service = service
self.timeout = timeout self.timeout = timeout
self.qobuz_region = qobuz_region self.qobuz_region = qobuz_region
@@ -89,7 +88,7 @@ class DownloadWorker(QThread):
elif self.service == "tidal_api": elif self.service == "tidal_api":
downloader = TidalDownloader(timeout=self.timeout) downloader = TidalDownloader(timeout=self.timeout)
else: else:
downloader = LucidaDownloader(self.use_fallback, self.timeout) downloader = LucidaDownloader(timeout=self.timeout)
def progress_update(current, total): def progress_update(current, total):
if total > 0: if total > 0:
@@ -193,19 +192,29 @@ class DownloadWorker(QThread):
self.progress.emit(f"Download stopped by user for: {track.title}",0) self.progress.emit(f"Download stopped by user for: {track.title}",0)
return return
elif isinstance(download_result_details, dict) and download_result_details.get("success") == False: elif isinstance(download_result_details, dict) and download_result_details.get("success") == False:
raise Exception(download_result_details.get("error", "Tidal API download failed")) raise Exception(download_result_details.get("error", "Tidal API download failed"))
elif isinstance(download_result_details, dict) and download_result_details.get("status") == "all_skipped" or download_result_details.get("status") == "skipped_exists": elif isinstance(download_result_details, dict) and (download_result_details.get("status") == "all_skipped" or download_result_details.get("status") == "skipped_exists"):
self.progress.emit(f"File already exists or skipped: {new_filename}",0) self.progress.emit(f"File already exists or skipped: {new_filename}",0)
downloaded_file = new_filepath downloaded_file = new_filepath
else: else:
downloaded_file = None downloaded_file = None
raise Exception(f"Tidal API download failed or returned unexpected result: {download_result_details}") raise Exception(f"Tidal API download failed or returned unexpected result: {download_result_details}")
else: else:
track_id = track.id track_id = track.id
self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0) self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0)
metadata = downloader.get_track_info(track_id, self.service) import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
metadata = loop.run_until_complete(downloader.get_track_info(track_id, self.service))
self.progress.emit(f"Track info received, starting download process", 0) self.progress.emit(f"Track info received, starting download process", 0)
is_paused_callback = lambda: self.is_paused is_paused_callback = lambda: self.is_paused
@@ -307,7 +316,7 @@ class ServiceStatusChecker(QThread):
services_status = {} services_status = {}
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
current_services = data.get('all', {}).get('downloads', {}).get('current', {}).get('services', {}) current_services = data.get('downloads', {}).get('current', {}).get('services', {})
services_status['amazon'] = current_services.get('amazon', 0) > 0 services_status['amazon'] = current_services.get('amazon', 0) > 0
services_status['tidal'] = current_services.get('tidal', 0) > 0 services_status['tidal'] = current_services.get('tidal', 0) > 0
services_status['deezer'] = current_services.get('deezer', 0) > 0 services_status['deezer'] = current_services.get('deezer', 0) > 0
@@ -324,8 +333,8 @@ class TidalStatusChecker(QThread):
def run(self): def run(self):
try: try:
response = requests.get("https://tidal.401658.xyz", timeout=5) response = requests.get("https://hifi.401658.xyz", timeout=5)
is_online = response.status_code == 200 is_online = response.status_code == 200 or response.status_code == 429
self.status_updated.emit(is_online) self.status_updated.emit(is_online)
except Exception as e: except Exception as e:
self.error.emit(f"Error checking Tidal (API) status: {str(e)}") self.error.emit(f"Error checking Tidal (API) status: {str(e)}")
@@ -398,10 +407,10 @@ class ServiceComboBox(QComboBox):
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
self.services = [ self.services = [
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False}, {'id': 'tidal', 'name': 'Tidal (Lucida)', 'icon': 'tidal.png', 'online': False},
{'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False}, {'id': 'amazon', 'name': 'Amazon (Lucida)', 'icon': 'amazon.png', 'online': False},
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False}, {'id': 'deezer', 'name': 'Deezer (Lucida)', 'icon': 'deezer.png', 'online': False},
{'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png', 'online': False}, {'id': 'qobuz', 'name': 'Qobuz (SquidWTF)', 'icon': 'qobuz.png', 'online': False},
{'id': 'tidal_api', 'name': 'Tidal (API)', 'icon': 'tidal.png', 'online': False} {'id': 'tidal_api', 'name': 'Tidal (API)', 'icon': 'tidal.png', 'online': False}
] ]
@@ -550,7 +559,7 @@ class QobuzRegionComboBox(QComboBox):
class SpotiFLACGUI(QWidget): class SpotiFLACGUI(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.current_version = "2.8" self.current_version = "3.4"
self.tracks = [] self.tracks = []
self.reset_state() self.reset_state()
@@ -561,7 +570,6 @@ class SpotiFLACGUI(QWidget):
self.filename_format = self.settings.value('filename_format', 'title_artist') self.filename_format = self.settings.value('filename_format', 'title_artist')
self.use_track_numbers = self.settings.value('use_track_numbers', False, type=bool) self.use_track_numbers = self.settings.value('use_track_numbers', False, type=bool)
self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool) self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool)
self.use_fallback = self.settings.value('use_fallback', False, type=bool)
self.service = self.settings.value('service', 'amazon') self.service = self.settings.value('service', 'amazon')
self.qobuz_region = self.settings.value('qobuz_region', 'us') self.qobuz_region = self.settings.value('qobuz_region', 'us')
self.timeout_value = self.settings.value('timeout_value', 30, type=int) self.timeout_value = self.settings.value('timeout_value', 30, type=int)
@@ -900,12 +908,6 @@ class SpotiFLACGUI(QWidget):
service_fallback_layout.addSpacing(10) service_fallback_layout.addSpacing(10)
self.fallback_checkbox = QCheckBox('Fallback')
self.fallback_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
self.fallback_checkbox.setChecked(self.use_fallback)
self.fallback_checkbox.toggled.connect(self.save_fallback_setting)
service_fallback_layout.addWidget(self.fallback_checkbox)
timeout_label = QLabel('Timeout:') timeout_label = QLabel('Timeout:')
self.timeout_input = QLineEdit() self.timeout_input = QLineEdit()
self.timeout_input.setText(str(self.timeout_value)) self.timeout_input.setText(str(self.timeout_value))
@@ -954,7 +956,7 @@ class SpotiFLACGUI(QWidget):
sections = [ sections = [
("Check for Updates", "https://github.com/afkarxyz/SpotiFLAC/releases"), ("Check for Updates", "https://github.com/afkarxyz/SpotiFLAC/releases"),
("Report an Issue", "https://github.com/afkarxyz/SpotiFLAC/issues"), ("Report an Issue", "https://github.com/afkarxyz/SpotiFLAC/issues"),
("Lucida Site", "https://lucida.to/stats") ("Lucida Status", "https://status.lucida.to")
] ]
for title, url in sections: for title, url in sections:
@@ -995,7 +997,7 @@ class SpotiFLACGUI(QWidget):
spacer = QSpacerItem(20, 6, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) spacer = QSpacerItem(20, 6, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
about_layout.addItem(spacer) about_layout.addItem(spacer)
footer_label = QLabel("v2.8 | May 2025") footer_label = QLabel("v3.4 | June 2025")
footer_label.setStyleSheet("font-size: 12px; margin-top: 10px;") footer_label.setStyleSheet("font-size: 12px; margin-top: 10px;")
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter) about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
@@ -1021,7 +1023,6 @@ class SpotiFLACGUI(QWidget):
break break
if service == "qobuz": if service == "qobuz":
self.fallback_checkbox.hide()
self.timeout_input.hide() self.timeout_input.hide()
if timeout_label: if timeout_label:
timeout_label.hide() timeout_label.hide()
@@ -1030,7 +1031,6 @@ class SpotiFLACGUI(QWidget):
region_label.show() region_label.show()
self.qobuz_region_dropdown.show() self.qobuz_region_dropdown.show()
elif service == "tidal_api": elif service == "tidal_api":
self.fallback_checkbox.hide()
self.timeout_input.hide() self.timeout_input.hide()
if timeout_label: if timeout_label:
timeout_label.hide() timeout_label.hide()
@@ -1038,7 +1038,6 @@ class SpotiFLACGUI(QWidget):
region_label.hide() region_label.hide()
self.qobuz_region_dropdown.hide() self.qobuz_region_dropdown.hide()
else: else:
self.fallback_checkbox.show()
self.timeout_input.show() self.timeout_input.show()
if timeout_label: if timeout_label:
timeout_label.show() timeout_label.show()
@@ -1068,12 +1067,6 @@ class SpotiFLACGUI(QWidget):
self.settings.setValue('use_album_subfolders', self.use_album_subfolders) self.settings.setValue('use_album_subfolders', self.use_album_subfolders)
self.settings.sync() self.settings.sync()
def save_fallback_setting(self):
self.use_fallback = self.fallback_checkbox.isChecked()
self.settings.setValue('use_fallback', self.use_fallback)
self.settings.sync()
self.log_output.append("Fallback setting saved successfully!")
def save_timeout_setting(self): def save_timeout_setting(self):
try: try:
timeout = int(self.timeout_input.text()) timeout = int(self.timeout_input.text())
@@ -1355,7 +1348,8 @@ class SpotiFLACGUI(QWidget):
def download_tracks(self, indices): def download_tracks(self, indices):
self.log_output.clear() self.log_output.clear()
outpath = self.output_dir.text() raw_outpath = self.output_dir.text().strip()
outpath = os.path.normpath(raw_outpath)
if not os.path.exists(outpath): if not os.path.exists(outpath):
self.log_output.append('Warning: Invalid output directory.') self.log_output.append('Warning: Invalid output directory.')
return return
@@ -1363,7 +1357,8 @@ class SpotiFLACGUI(QWidget):
tracks_to_download = self.tracks if self.is_single_track else [self.tracks[i] for i in indices] tracks_to_download = self.tracks if self.is_single_track else [self.tracks[i] for i in indices]
if self.is_album or self.is_playlist: if self.is_album or self.is_playlist:
folder_name = re.sub(r'[<>:"/\\|?*]', '_', self.album_or_playlist_name) name = self.album_or_playlist_name.strip()
folder_name = re.sub(r'[<>:"/\\|?*]', '_', name)
outpath = os.path.join(outpath, folder_name) outpath = os.path.join(outpath, folder_name)
os.makedirs(outpath, exist_ok=True) os.makedirs(outpath, exist_ok=True)
@@ -1386,7 +1381,6 @@ class SpotiFLACGUI(QWidget):
self.filename_format, self.filename_format,
self.use_track_numbers, self.use_track_numbers,
self.use_album_subfolders, self.use_album_subfolders,
self.use_fallback,
service, service,
self.timeout_value, self.timeout_value,
qobuz_region qobuz_region
+1 -1
View File
@@ -32,7 +32,7 @@ def generate_totp(
counter * 30_000, counter * 30_000,
) )
token_url = 'https://open.spotify.com/get_access_token' 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/{}'
+39 -15
View File
@@ -21,7 +21,7 @@ class ProgressCallback:
print(f"\r{current / (1024 * 1024):.2f} MB", end="") print(f"\r{current / (1024 * 1024):.2f} MB", end="")
class LucidaDownloader: class LucidaDownloader:
def __init__(self, domain="to", timeout=30): def __init__(self, timeout=30):
self.client = requests.Session() self.client = requests.Session()
self.headers = { self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
@@ -29,10 +29,7 @@ class LucidaDownloader:
self.progress_callback = ProgressCallback() self.progress_callback = ProgressCallback()
self.timeout = timeout self.timeout = timeout
if domain not in ["to", "su"]: self.base_domain = "lucida.to"
raise ValueError("Domain must be either 'to' or 'su'")
self.base_domain = f"lucida.{domain}"
def set_progress_callback(self, callback): def set_progress_callback(self, callback):
self.progress_callback = callback self.progress_callback = callback
@@ -352,7 +349,9 @@ class SquidWTFDownloader:
items = data.get("data", {}).get("tracks", {}).get("items", []) items = data.get("data", {}).get("tracks", {}).get("items", [])
priority = {24: 1, 16: 2} priority = {24: 1, 16: 2}
for track in items: for track in items:
if track.get("isrc") == isrc: track_isrc = track.get("isrc", "").upper()
search_isrc = isrc.upper()
if track_isrc == search_isrc:
current_prio = priority.get(track.get("maximum_bit_depth"), 3) 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): if selected_track is None or current_prio < priority.get(selected_track.get("maximum_bit_depth"), 3):
selected_track = track selected_track = track
@@ -646,7 +645,7 @@ class TidalDownloader:
return result return result
if isrc: if isrc:
isrc_items = [item for item in result["items"] if item.get("isrc") == isrc] isrc_items = [item for item in result["items"] if item.get("isrc", "").upper() == isrc.upper()]
if len(isrc_items) > 1: if len(isrc_items) > 1:
hires_items = [] hires_items = []
@@ -672,7 +671,7 @@ class TidalDownloader:
async def get_track_download_info(self, track_id, quality="LOSSLESS"): async def get_track_download_info(self, track_id, quality="LOSSLESS"):
try: try:
download_api_url = f"https://tidal.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: async with httpx.AsyncClient(http2=True, timeout=self.timeout) as client:
response = await client.get(download_api_url) response = await client.get(download_api_url)
@@ -764,8 +763,18 @@ class TidalDownloader:
if copyright_info: if copyright_info:
audio["COPYRIGHT"] = copyright_info audio["COPYRIGHT"] = copyright_info
if album_info.get("releaseDate"): release_date = None
audio["DATE"] = album_info["releaseDate"][:4] if search_info and search_info.get("streamStartDate"):
release_date = search_info["streamStartDate"]
elif track_info.get("streamStartDate"):
release_date = track_info["streamStartDate"]
if release_date:
if "T" in release_date:
date_part = release_date.split("T")[0]
audio["DATE"] = date_part
else:
audio["DATE"] = release_date
if track_info.get("genre"): if track_info.get("genre"):
audio["GENRE"] = track_info["genre"] audio["GENRE"] = track_info["genre"]
@@ -963,9 +972,17 @@ class TidalDownloader:
print(f"Search error: {search_result['error']}") print(f"Search error: {search_result['error']}")
return {"success": False, "error": search_result['error']} return {"success": False, "error": search_result['error']}
raw_result = None
if isrc:
raw_result = await self.search_tracks(query)
if not search_result["items"]: if not search_result["items"]:
print("No tracks found") if isrc and raw_result and raw_result.get("items"):
return {"success": False, "error": "No tracks found"} print("No tracks found with ISRC filter, falling back to unfiltered search")
search_result = raw_result
else:
print("No tracks found")
return {"success": False, "error": "No tracks found"}
track_ids = [item["id"] for item in search_result["items"]] track_ids = [item["id"] for item in search_result["items"]]
print(f"Found {len(track_ids)} track(s): {track_ids}") print(f"Found {len(track_ids)} track(s): {track_ids}")
@@ -995,9 +1012,16 @@ class TidalDownloader:
raise Exception("Download stopped by user") raise Exception("Download stopped by user")
raise Exception(result["error"]) raise Exception(result["error"])
def print_progress(current, total):
if total > 0:
percent = (current / total) * 100
print(f"\rProgress: {percent:.2f}% ({current}/{total})", end="")
else:
print(f"\rDownloaded: {current / (1024 * 1024):.2f} MB", end="")
async def main(): async def main():
print("=== LucidaDownloader ===") print("=== LucidaDownloader ===")
lucida = LucidaDownloader(domain="to") lucida = LucidaDownloader()
track_id = "2plbrEY59IikOBgBGLjaoe" track_id = "2plbrEY59IikOBgBGLjaoe"
service = "tidal" service = "tidal"
output_dir = "." output_dir = "."
@@ -1006,7 +1030,7 @@ async def main():
print(f"Getting track: {track_id} from {service}") print(f"Getting track: {track_id} from {service}")
metadata = await lucida.get_track_info(track_id, service) metadata = await lucida.get_track_info(track_id, service)
print("Starting download") print("Starting download")
downloaded_file = await lucida.download(metadata, output_dir) downloaded_file = lucida.download(metadata, 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)}")
@@ -1014,7 +1038,7 @@ async def main():
print("\n\n=== SquidWTFDownloader ===") print("\n\n=== SquidWTFDownloader ===")
squid = SquidWTFDownloader(region="us") squid = SquidWTFDownloader(region="us")
isrc = "USUM72409273" isrc = "USAT22409172"
output_dir = "." output_dir = "."
try: try:
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "2.7" "version": "3.3"
} }