This commit is contained in:
afkarxyz
2025-05-23 16:43:45 +07:00
parent 46cb65665e
commit 4bc164cc56
2 changed files with 601 additions and 48 deletions
+126 -42
View File
@@ -10,14 +10,14 @@ from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit,
QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton, QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton,
QAbstractItemView, QSpacerItem, QSizePolicy, QProgressBar, QCheckBox, QDialog, QAbstractItemView, QSpacerItem, QSizePolicy, QProgressBar, QCheckBox, QDialog,
QDialogButtonBox, QComboBox, QStyledItemDelegate, QStyle QDialogButtonBox, QComboBox, QStyledItemDelegate
) )
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer, QTime, QSettings, QSize from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer, QTime, QSettings, QSize
from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush, QPalette from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
from getTracks import LucidaDownloader, SquidWTFDownloader from getTracks import LucidaDownloader, SquidWTFDownloader, TidalDownloader
@dataclass @dataclass
class Track: class Track:
@@ -86,6 +86,8 @@ class DownloadWorker(QThread):
try: try:
if self.service == "qobuz": if self.service == "qobuz":
downloader = SquidWTFDownloader(self.qobuz_region, self.timeout) downloader = SquidWTFDownloader(self.qobuz_region, self.timeout)
elif self.service == "tidal_api":
downloader = TidalDownloader(timeout=self.timeout)
else: else:
downloader = LucidaDownloader(self.use_fallback, self.timeout) downloader = LucidaDownloader(self.use_fallback, self.timeout)
@@ -141,7 +143,7 @@ class DownloadWorker(QThread):
self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0) self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0)
self.failed_tracks.append((track.title, track.artists, "No ISRC available")) self.failed_tracks.append((track.title, track.artists, "No ISRC available"))
continue continue
self.progress.emit(f"Getting track from Qobuz with ISRC: {track.isrc}", 0) self.progress.emit(f"Getting track from Qobuz with ISRC: {track.isrc}", 0)
is_paused_callback = lambda: self.is_paused is_paused_callback = lambda: self.is_paused
@@ -153,13 +155,57 @@ class DownloadWorker(QThread):
is_paused_callback=is_paused_callback, is_paused_callback=is_paused_callback,
is_stopped_callback=is_stopped_callback is_stopped_callback=is_stopped_callback
) )
else: elif self.service == "tidal_api":
if not track.isrc:
self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0)
self.failed_tracks.append((track.title, track.artists, "No ISRC available"))
continue
self.progress.emit(f"Searching and downloading from Tidal (API) for ISRC: {track.isrc} - {track.title} - {track.artists}", 0)
import asyncio
is_paused_callback = lambda: self.is_paused
is_stopped_callback = lambda: self.is_stopped
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)
download_result_details = loop.run_until_complete(downloader.download(
query=f"{track.title} {track.artists}",
isrc=track.isrc,
output_dir=track_outpath,
quality="LOSSLESS",
embed_metadata=True,
is_paused_callback=is_paused_callback,
is_stopped_callback=is_stopped_callback
))
if isinstance(download_result_details, str) and os.path.exists(download_result_details):
downloaded_file = download_result_details
elif isinstance(download_result_details, dict) and download_result_details.get("success") == False and download_result_details.get("error") == "Download stopped by user":
self.progress.emit(f"Download stopped by user for: {track.title}",0)
return
elif isinstance(download_result_details, dict) and download_result_details.get("success") == False:
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":
self.progress.emit(f"File already exists or skipped: {new_filename}",0)
downloaded_file = new_filepath
else:
downloaded_file = None
raise Exception(f"Tidal API download failed or returned unexpected result: {download_result_details}")
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)
import asyncio metadata = downloader.get_track_info(track_id, self.service)
metadata = asyncio.run(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
@@ -172,7 +218,10 @@ class DownloadWorker(QThread):
is_stopped_callback=is_stopped_callback is_stopped_callback=is_stopped_callback
) )
if downloaded_file == new_filepath: if self.is_stopped:
return
if downloaded_file == new_filepath:
self.progress.emit(f"File already exists: {new_filename}", 0) self.progress.emit(f"File already exists: {new_filename}", 0)
self.progress.emit(f"Skipped: {track.title} - {track.artists}", self.progress.emit(f"Skipped: {track.title} - {track.artists}",
int((i + 1) / total_tracks * 100)) int((i + 1) / total_tracks * 100))
@@ -255,21 +304,32 @@ class ServiceStatusChecker(QThread):
def run(self): def run(self):
try: try:
response = requests.get("https://lucida.to/api/stats", timeout=5) response = requests.get("https://lucida.to/api/stats", timeout=5)
services_status = {}
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
services_status = {}
current_services = data.get('all', {}).get('downloads', {}).get('current', {}).get('services', {}) current_services = data.get('all', {}).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
self.status_updated.emit(services_status)
else: else:
self.error.emit(f"Server returned status code: {response.status_code}") self.error.emit(f"Lucida API error: {response.status_code}")
self.status_updated.emit(services_status)
except Exception as e: except Exception as e:
self.error.emit(f"Error checking service status: {str(e)}") self.error.emit(f"Error checking Lucida service status: {str(e)}")
class TidalStatusChecker(QThread):
status_updated = pyqtSignal(bool)
error = pyqtSignal(str)
def run(self):
try:
response = requests.get("https://tidal.401658.xyz", timeout=5)
is_online = response.status_code == 200
self.status_updated.emit(is_online)
except Exception as e:
self.error.emit(f"Error checking Tidal (API) status: {str(e)}")
self.status_updated.emit(False)
class QobuzStatusChecker(QThread): class QobuzStatusChecker(QThread):
status_updated = pyqtSignal(bool) status_updated = pyqtSignal(bool)
@@ -294,11 +354,6 @@ class StatusIndicatorDelegate(QStyledItemDelegate):
super().paint(painter, option, index) super().paint(painter, option, index)
if option.state & QStyle.StateFlag.State_Selected:
text_color = option.palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText)
else:
text_color = option.palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.Text)
indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red
circle_size = 6 circle_size = 6
@@ -323,12 +378,21 @@ class ServiceComboBox(QComboBox):
self.status_checker = ServiceStatusChecker() self.status_checker = ServiceStatusChecker()
self.status_checker.status_updated.connect(self.update_service_status) self.status_checker.status_updated.connect(self.update_service_status)
self.status_checker.error.connect(lambda e: print(f"Status check error: {e}")) self.status_checker.error.connect(lambda e: print(f"General status check error: {e}"))
self.status_checker.start() self.status_checker.start()
self.status_timer = QTimer(self) self.status_timer = QTimer(self)
self.status_timer.timeout.connect(self.refresh_status) self.status_timer.timeout.connect(self.refresh_status)
self.status_timer.start(5000) self.status_timer.start(5000)
self.tidal_api_status_checker = TidalStatusChecker()
self.tidal_api_status_checker.status_updated.connect(self.update_tidal_api_service_status)
self.tidal_api_status_checker.error.connect(lambda e: print(f"Tidal (API) status check error: {e}"))
self.tidal_api_status_checker.start()
self.tidal_api_status_timer = QTimer(self)
self.tidal_api_status_timer.timeout.connect(self.refresh_tidal_api_status)
self.tidal_api_status_timer.start(6000)
def setup_items(self): def setup_items(self):
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
@@ -337,7 +401,8 @@ class ServiceComboBox(QComboBox):
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False}, {'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False},
{'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False}, {'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False},
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False}, {'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False},
{'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png', 'online': False} {'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png', 'online': False},
{'id': 'tidal_api', 'name': 'Tidal (API)', 'icon': 'tidal.png', 'online': False}
] ]
for service in self.services: for service in self.services:
@@ -358,12 +423,12 @@ class ServiceComboBox(QComboBox):
pixmap.save(path) pixmap.save(path)
def update_service_status(self, status_dict): def update_service_status(self, status_dict):
self.services_status = status_dict self.services_status.update(status_dict)
for i in range(self.count()): for i in range(self.count()):
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1) service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
if service_id in self.services_status: if service_id in self.services_status:
service_data = self.itemData(i, Qt.ItemDataRole.UserRole) service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
if isinstance(service_data, dict): if isinstance(service_data, dict):
service_data['online'] = self.services_status[service_id] service_data['online'] = self.services_status[service_id]
@@ -374,8 +439,25 @@ class ServiceComboBox(QComboBox):
def refresh_status(self): def refresh_status(self):
self.status_checker = ServiceStatusChecker() self.status_checker = ServiceStatusChecker()
self.status_checker.status_updated.connect(self.update_service_status) self.status_checker.status_updated.connect(self.update_service_status)
self.status_checker.error.connect(lambda e: print(f"Status check error: {e}")) self.status_checker.error.connect(lambda e: print(f"General status check error: {e}"))
self.status_checker.start() self.status_checker.start()
def update_tidal_api_service_status(self, is_online):
for i in range(self.count()):
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
if service_id == 'tidal_api':
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
if isinstance(service_data, dict):
service_data['online'] = is_online
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
break
self.update()
def refresh_tidal_api_status(self):
self.tidal_api_status_checker = TidalStatusChecker()
self.tidal_api_status_checker.status_updated.connect(self.update_tidal_api_service_status)
self.tidal_api_status_checker.error.connect(lambda e: print(f"Tidal (API) status check error: {e}"))
self.tidal_api_status_checker.start()
def currentData(self, role=Qt.ItemDataRole.UserRole + 1): def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
return super().currentData(role) return super().currentData(role)
@@ -468,7 +550,7 @@ class QobuzRegionComboBox(QComboBox):
class SpotiFLACGUI(QWidget): class SpotiFLACGUI(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.current_version = "2.7" self.current_version = "2.8"
self.tracks = [] self.tracks = []
self.reset_state() self.reset_state()
@@ -913,7 +995,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.7 | May 2025") footer_label = QLabel("v2.8 | May 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)
@@ -932,33 +1014,35 @@ class SpotiFLACGUI(QWidget):
timeout_label = widget timeout_label = widget
break break
region_label = None
for widget in self.qobuz_region_dropdown.parentWidget().children():
if isinstance(widget, QLabel) and widget.text() == "Region:":
region_label = widget
break
if service == "qobuz": if service == "qobuz":
self.fallback_checkbox.hide() self.fallback_checkbox.hide()
self.timeout_input.hide() self.timeout_input.hide()
if timeout_label: if timeout_label:
timeout_label.hide() timeout_label.hide()
region_label = None
for widget in self.qobuz_region_dropdown.parentWidget().children():
if isinstance(widget, QLabel) and widget.text() == "Region:":
region_label = widget
break
if region_label: if region_label:
region_label.show() region_label.show()
self.qobuz_region_dropdown.show() self.qobuz_region_dropdown.show()
else: elif service == "tidal_api":
self.fallback_checkbox.hide()
self.timeout_input.hide()
if timeout_label:
timeout_label.hide()
if region_label:
region_label.hide()
self.qobuz_region_dropdown.hide()
else:
self.fallback_checkbox.show() self.fallback_checkbox.show()
self.timeout_input.show() self.timeout_input.show()
if timeout_label: if timeout_label:
timeout_label.show() timeout_label.show()
region_label = None
for widget in self.qobuz_region_dropdown.parentWidget().children():
if isinstance(widget, QLabel) and widget.text() == "Region:":
region_label = widget
break
if region_label: if region_label:
region_label.hide() region_label.hide()
self.qobuz_region_dropdown.hide() self.qobuz_region_dropdown.hide()
+475 -6
View File
@@ -4,6 +4,10 @@ import os
import asyncio import asyncio
import re import re
import base64 import base64
import json
import tempfile
import httpx
import aiofiles
from datetime import datetime from datetime import datetime
from mutagen.flac import FLAC, Picture from mutagen.flac import FLAC, Picture
from mutagen.id3 import PictureType from mutagen.id3 import PictureType
@@ -537,28 +541,480 @@ class SquidWTFDownloader:
except Exception as e: except Exception as e:
raise Exception(f"Metadata error: {e}") raise Exception(f"Metadata error: {e}")
class TidalDownloader:
def __init__(self, client_id="zU4XHVVkc2tDPo4t", client_secret="VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=", timeout=30):
self.client_id = client_id
self.client_secret = client_secret
self.timeout = timeout
self.progress_callback = ProgressCallback()
self.temp_dir = tempfile.gettempdir()
self.token_path = os.path.join(self.temp_dir, "tidal_token.json")
self.access_token = None
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 set_progress_callback(self, callback):
self.progress_callback = callback
async def get_access_token(self):
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
payload = {
"client_id": self.client_id,
"grant_type": "client_credentials",
}
async with httpx.AsyncClient(http2=True) as client:
try:
response = await client.post(
url=refresh_url,
data=payload,
auth=(self.client_id, self.client_secret),
)
if response.status_code == 200:
token_data = response.json()
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
return None
except:
return None
async def search_tracks(self, query):
try:
tidal_token = self.access_token or await self.get_access_token()
if not tidal_token:
return {"error": "Failed to get access token"}
search_url = f"https://api.tidal.com/v1/search/tracks?query={query}&limit=25&offset=0&countryCode=US"
header = {"authorization": f"Bearer {tidal_token}"}
async with httpx.AsyncClient(http2=True) as client:
search_data = await client.get(url=search_url, headers=header)
response_data = search_data.json()
filtered_items = [{
"id": item.get("id"),
"title": item.get("title"),
"url": item.get("url"),
"isrc": item.get("isrc"),
"audioQuality": item.get("audioQuality"),
"mediaMetadata": item.get("mediaMetadata"),
"album": item.get("album", {}),
"artists": item.get("artists", []),
"artist": item.get("artist", {}),
"trackNumber": item.get("trackNumber"),
"volumeNumber": item.get("volumeNumber"),
"duration": item.get("duration"),
"copyright": item.get("copyright"),
"explicit": item.get("explicit")
} for item in response_data.get("items", [])]
return {
"limit": response_data.get("limit"),
"offset": response_data.get("offset"),
"totalNumberOfItems": response_data.get("totalNumberOfItems"),
"items": filtered_items
}
except Exception as e:
return {"error": f"Error: {str(e)}"}
async def filter_by_isrc(self, query, isrc=None):
try:
result = await self.search_tracks(query)
if "error" in result:
return result
if isrc:
isrc_items = [item for item in result["items"] if item.get("isrc") == isrc]
if len(isrc_items) > 1:
hires_items = []
for item in isrc_items:
media_metadata = item.get("mediaMetadata", {})
tags = media_metadata.get("tags", []) if media_metadata else []
if "HIRES_LOSSLESS" in tags:
hires_items.append(item)
if hires_items:
result["items"] = hires_items
else:
result["items"] = isrc_items
else:
result["items"] = isrc_items
result["totalNumberOfItems"] = len(result["items"])
return result
except Exception as e:
return {"error": f"Error: {str(e)}"}
async def get_track_download_info(self, track_id, quality="LOSSLESS"):
try:
download_api_url = f"https://tidal.401658.xyz/track/?id={track_id}&quality={quality}"
async with httpx.AsyncClient(http2=True, timeout=self.timeout) as client:
response = await client.get(download_api_url)
if response.status_code == 200:
data = response.json()
for item in data:
if "OriginalTrackUrl" in item:
return {
"success": True,
"download_url": item["OriginalTrackUrl"],
"track_info": data[0] if data else {}
}
return {"success": False, "error": "OriginalTrackUrl not found in response"}
else:
return {"success": False, "error": f"API returned status code: {response.status_code}"}
except Exception as e:
return {"success": False, "error": f"Error getting download info: {str(e)}"}
async 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)
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 embed_metadata(self, filepath, track_info, search_info=None):
try:
audio = FLAC(filepath)
audio.clear()
if track_info.get("title"):
audio["TITLE"] = track_info["title"]
artists_list = []
if search_info and search_info.get("artists"):
for artist in search_info["artists"]:
if artist.get("name"):
artists_list.append(artist["name"])
elif search_info and search_info.get("artist") and search_info["artist"].get("name"):
artists_list.append(search_info["artist"]["name"])
elif track_info.get("artists"):
for artist in track_info["artists"]:
if artist.get("name"):
artists_list.append(artist["name"])
elif track_info.get("artist") and track_info["artist"].get("name"):
artists_list.append(track_info["artist"]["name"])
if artists_list:
audio["ARTIST"] = artists_list[0]
if len(artists_list) > 1:
audio["ALBUMARTIST"] = "; ".join(artists_list)
else:
audio["ALBUMARTIST"] = artists_list[0]
album_info = search_info.get("album", {}) if search_info else track_info.get("album", {})
if album_info.get("title"):
audio["ALBUM"] = album_info["title"]
if search_info and search_info.get("trackNumber"):
audio["TRACKNUMBER"] = str(search_info["trackNumber"])
elif track_info.get("trackNumber"):
audio["TRACKNUMBER"] = str(track_info["trackNumber"])
if search_info and search_info.get("volumeNumber"):
audio["DISCNUMBER"] = str(search_info["volumeNumber"])
elif track_info.get("volumeNumber"):
audio["DISCNUMBER"] = str(track_info["volumeNumber"])
isrc = search_info.get("isrc") if search_info else track_info.get("isrc")
if isrc:
audio["ISRC"] = isrc
copyright_info = search_info.get("copyright") if search_info else track_info.get("copyright")
if copyright_info:
audio["COPYRIGHT"] = copyright_info
if album_info.get("releaseDate"):
audio["DATE"] = album_info["releaseDate"][:4]
if track_info.get("genre"):
audio["GENRE"] = track_info["genre"]
if album_info.get("cover"):
album_art = await self.download_album_art(album_info["cover"])
if album_art:
picture = Picture()
picture.data = album_art
picture.type = PictureType.COVER_FRONT
picture.mime = "image/jpeg"
picture.desc = "Cover"
audio.add_picture(picture)
print("Album art embedded successfully")
audio.save()
print(f"Metadata embedded successfully for: {track_info.get('title', 'Unknown')}")
return True
except Exception as e:
print(f"Error embedding metadata: {str(e)}")
return False
async def download_file(self, url, filename, max_retries=3, is_paused_callback=None, is_stopped_callback=None):
for attempt in range(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:
total_size_in_bytes = int(response.headers.get('content-length', 0))
bytes_downloaded = 0
async with aiofiles.open(filename, 'wb') as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
if is_stopped_callback and is_stopped_callback():
print("\\nDownload stopped.")
if os.path.exists(filename):
try:
os.remove(filename)
except OSError as e:
print(f"Error removing partial file: {e}")
return {"success": False, "error": "Download stopped by user"}
while is_paused_callback and is_paused_callback():
print("\\nDownload paused. Waiting...")
await asyncio.sleep(1)
await f.write(chunk)
bytes_downloaded += len(chunk)
if total_size_in_bytes > 0:
if self.progress_callback:
self.progress_callback(bytes_downloaded, total_size_in_bytes)
if total_size_in_bytes > 0 and bytes_downloaded == total_size_in_bytes:
print()
print(f"Successfully downloaded: {filename} ({bytes_downloaded} bytes)")
return {"success": True, "size": bytes_downloaded}
else:
print(f"\\nFailed to download {filename}. HTTP Status: {response.status_code}")
if os.path.exists(filename):
try:
os.remove(filename)
except OSError as e:
print(f"Error removing partial file after server error: {e}")
return {"success": False, "error": f"HTTP {response.status_code}"}
except Exception as e:
print()
if os.path.exists(filename):
try:
os.remove(filename)
except OSError as ose:
print(f"Error removing partial file after exception: {ose}")
if attempt < max_retries - 1:
print(f"Download attempt {attempt + 1} failed, retrying...")
await asyncio.sleep(2)
else:
return {"success": False, "error": f"Download failed after {max_retries} attempts: {str(e)}"}
async def download_track(self, track_ids, search_results, output_dir=".", quality="LOSSLESS", embed_meta=True, is_paused_callback=None, is_stopped_callback=None):
if not isinstance(track_ids, list):
track_ids = [track_ids]
if output_dir != ".":
os.makedirs(output_dir, exist_ok=True)
search_map = {}
if search_results and search_results.get("items"):
for item in search_results["items"]:
search_map[item["id"]] = item
all_skipped = True
skipped_files = []
for i, track_id in enumerate(track_ids):
download_info = await self.get_track_download_info(track_id, quality)
if not download_info["success"]:
print(f"Failed to get download info for track {track_id}: {download_info['error']}")
continue
download_url = download_info["download_url"]
track_info = download_info["track_info"]
search_info = search_map.get(track_id)
title = track_info.get("title", f"track_{track_id}")
artists_list = []
if search_info and search_info.get("artists"):
for artist in search_info["artists"]:
if artist.get("name"):
artists_list.append(artist["name"])
elif search_info and search_info.get("artist") and search_info["artist"].get("name"):
artists_list.append(search_info["artist"]["name"])
elif track_info.get("artists"):
for artist in track_info["artists"]:
if artist.get("name"):
artists_list.append(artist["name"])
elif track_info.get("artist") and track_info["artist"].get("name"):
artists_list.append(track_info["artist"]["name"])
artist_names = ", ".join(artists_list) if artists_list else ""
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).rstrip()
safe_artists = "".join(c for c in artist_names if c.isalnum() or c in (' ', '-', '_', ',', '.')).rstrip()
if safe_artists:
filename = f"{safe_title} - {safe_artists}.flac"
else:
filename = f"{safe_title}.flac"
filepath = os.path.join(output_dir, filename)
if os.path.exists(filepath):
print(f"File {filename} already exists. Skipping download.")
skipped_files.append(filename)
if len(track_ids) == 1:
return {
"success": True,
"status": "skipped_exists",
"track_id": track_id,
"filename": filename,
"filepath": filepath,
"message": f"File {filename} already exists."
}
continue
all_skipped = False
print(f"Downloading: {filename}")
download_result = await self.download_file(download_url, filepath, is_paused_callback=is_paused_callback, is_stopped_callback=is_stopped_callback)
if download_result["success"]:
print(f"Successfully downloaded track {track_id}")
if embed_meta:
print("Embedding metadata...")
await self.embed_metadata(filepath, track_info, search_info)
return {
"success": True,
"track_id": track_id,
"filename": filename,
"filepath": filepath,
"size": download_result["size"],
"track_info": track_info,
"metadata_embedded": embed_meta
}
else:
print(f"Failed to download track {track_id}: {download_result['error']}")
if os.path.exists(filepath):
try:
os.remove(filepath)
except:
pass
if download_result.get("error") == "Download stopped by user":
return {"success": False, "error": "Download stopped by user", "track_id": track_id}
if all_skipped and skipped_files:
return {
"success": True,
"status": "all_skipped",
"message": f"All files already exist: {', '.join(skipped_files)}"
}
return {"success": False, "error": "All track IDs failed to download or were stopped"}
async def search_and_download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", embed_metadata=True, is_paused_callback=None, is_stopped_callback=None):
print(f"Searching for: {query}")
if isrc:
print(f"ISRC: {isrc}")
search_result = await self.filter_by_isrc(query, isrc)
if "error" in search_result:
print(f"Search error: {search_result['error']}")
return {"success": False, "error": search_result['error']}
if not search_result["items"]:
print("No tracks found")
return {"success": False, "error": "No tracks found"}
track_ids = [item["id"] for item in search_result["items"]]
print(f"Found {len(track_ids)} track(s): {track_ids}")
download_result = await self.download_track(track_ids, search_result, output_dir, quality, embed_metadata, is_paused_callback=is_paused_callback, is_stopped_callback=is_stopped_callback)
return download_result
async def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", embed_metadata=True, is_paused_callback=None, is_stopped_callback=None):
result = await self.search_and_download(query, isrc, output_dir, quality, embed_metadata, is_paused_callback=is_paused_callback, is_stopped_callback=is_stopped_callback)
if result["success"]:
if result.get("status") == "all_skipped":
print(f"Skipped: {result['message']}")
if "filepath" in result:
return result["filepath"]
return output_dir
elif result.get("status") == "skipped_exists":
print(f"Skipped: {result['message']}")
return result["filepath"]
else:
print("Download completed!")
return result["filepath"]
else:
print(f"Download failed: {result['error']}")
if result.get("error") == "Download stopped by user":
raise Exception("Download stopped by user")
raise Exception(result["error"])
async def main(): async def main():
print("=== LucidaDownloader ===") print("=== LucidaDownloader ===")
lucida = LucidaDownloader(domain="to") lucida = LucidaDownloader(domain="to")
track_id = "2plbrEY59IikOBgBGLjaoe" track_id = "2plbrEY59IikOBgBGLjaoe"
service = "tidal" service = "tidal"
output_dir = "." output_dir = "."
try: try:
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)}")
print("\n\n=== SquidWTFDownloader ===") print("\n\n=== SquidWTFDownloader ===")
squid = SquidWTFDownloader(region="us") squid = SquidWTFDownloader(region="us")
isrc = "TCAIT2495017" isrc = "USUM72409273"
output_dir = "." output_dir = "."
try: try:
@@ -566,6 +1022,19 @@ async def main():
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)}")
print("\n\n=== TidalDownloader ===")
tidal = TidalDownloader()
query = "APT."
isrc = "USAT22409172"
output_dir = "."
try:
downloaded_file = await tidal.download(query, isrc, output_dir, quality="LOSSLESS", embed_metadata=True)
print(f"Success: File saved as {downloaded_file}")
except Exception as e:
print(f"Error: {str(e)}")
if __name__ == "__main__": if __name__ == "__main__":
try: try: