Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55669ec45f | |||
| 3304b13828 | |||
| 579bb1415a | |||
| f0e71261a5 | |||
| e2ad51da34 | |||
| 9e403ab1ba | |||
| 7058559ddc | |||
| 861f303a4f |
@@ -3,10 +3,10 @@
|
||||

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

|
||||
|
||||

|
||||

|
||||
|
||||
## Lossless Audio Check
|
||||
|
||||
|
||||
+95
-295
@@ -20,11 +20,8 @@ from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush
|
||||
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
|
||||
from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
|
||||
from qobuzAutoDL import QobuzDownloader as QobuzAutoDownloader
|
||||
from qobuzRegionDL import QobuzDownloader as QobuzRegionDownloader
|
||||
from tidalDL import TidalDownloader
|
||||
from deezerDL import DeezerDownloader
|
||||
from amazonDL import LucidaDownloader
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
@@ -59,12 +56,12 @@ class MetadataFetchWorker(QThread):
|
||||
self.error.emit(f'Failed to fetch metadata: {str(e)}')
|
||||
|
||||
class DownloadWorker(QThread):
|
||||
finished = pyqtSignal(bool, str, list)
|
||||
finished = pyqtSignal(bool, str, list, list, list)
|
||||
progress = pyqtSignal(str, int)
|
||||
|
||||
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,
|
||||
use_artist_subfolders=False, use_album_subfolders=False, service="tidal", qobuz_region="us", qobuz_mode="auto"):
|
||||
use_artist_subfolders=False, use_album_subfolders=False, service="tidal"):
|
||||
super().__init__()
|
||||
self.tracks = tracks
|
||||
self.outpath = outpath
|
||||
@@ -77,11 +74,11 @@ class DownloadWorker(QThread):
|
||||
self.use_artist_subfolders = use_artist_subfolders
|
||||
self.use_album_subfolders = use_album_subfolders
|
||||
self.service = service
|
||||
self.qobuz_region = qobuz_region
|
||||
self.qobuz_mode = qobuz_mode
|
||||
self.is_paused = False
|
||||
self.is_stopped = False
|
||||
self.failed_tracks = []
|
||||
self.successful_tracks = []
|
||||
self.skipped_tracks = []
|
||||
|
||||
def get_formatted_filename(self, track):
|
||||
if self.filename_format == "artist_title":
|
||||
@@ -94,17 +91,10 @@ class DownloadWorker(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self.service == "qobuz":
|
||||
if self.qobuz_mode == "auto":
|
||||
downloader = QobuzAutoDownloader()
|
||||
else:
|
||||
downloader = QobuzRegionDownloader(self.qobuz_region)
|
||||
elif self.service == "tidal":
|
||||
if self.service == "tidal":
|
||||
downloader = TidalDownloader()
|
||||
elif self.service == "deezer":
|
||||
downloader = DeezerDownloader()
|
||||
elif self.service == "amazon":
|
||||
downloader = LucidaDownloader()
|
||||
else:
|
||||
downloader = TidalDownloader()
|
||||
|
||||
@@ -156,26 +146,10 @@ class DownloadWorker(QThread):
|
||||
self.progress.emit(f"File already exists: {new_filename}. Skipping download.", 0)
|
||||
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
self.skipped_tracks.append(track)
|
||||
continue
|
||||
|
||||
if self.service == "qobuz":
|
||||
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"Getting track from Qobuz with ISRC: {track.isrc}", 0)
|
||||
|
||||
is_paused_callback = lambda: self.is_paused
|
||||
is_stopped_callback = lambda: self.is_stopped
|
||||
|
||||
downloaded_file = downloader.download(
|
||||
track.isrc,
|
||||
track_outpath,
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
)
|
||||
elif self.service == "tidal":
|
||||
if self.service == "tidal":
|
||||
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"))
|
||||
@@ -204,6 +178,7 @@ class DownloadWorker(QThread):
|
||||
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
|
||||
self.skipped_tracks.append(track)
|
||||
else:
|
||||
downloaded_file = None
|
||||
raise Exception(f"Tidal download failed or returned unexpected result: {download_result_details}")
|
||||
@@ -232,21 +207,6 @@ class DownloadWorker(QThread):
|
||||
raise Exception("Downloaded file not found")
|
||||
else:
|
||||
raise Exception("Deezer download failed")
|
||||
elif self.service == "amazon":
|
||||
self.progress.emit(f"Downloading from Amazon Music: {track.title} - {track.artists}", 0)
|
||||
|
||||
is_paused_callback = lambda: self.is_paused
|
||||
is_stopped_callback = lambda: self.is_stopped
|
||||
|
||||
downloaded_file = downloader.download(
|
||||
track.id,
|
||||
track_outpath,
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
)
|
||||
|
||||
if not downloaded_file or not os.path.exists(downloaded_file):
|
||||
raise Exception("Amazon Music download failed")
|
||||
else:
|
||||
track_id = track.id
|
||||
self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0)
|
||||
@@ -280,6 +240,7 @@ class DownloadWorker(QThread):
|
||||
self.progress.emit(f"File already exists: {new_filename}", 0)
|
||||
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
self.skipped_tracks.append(track)
|
||||
continue
|
||||
|
||||
if downloaded_file != new_filepath:
|
||||
@@ -294,6 +255,7 @@ class DownloadWorker(QThread):
|
||||
|
||||
self.progress.emit(f"Successfully downloaded: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
self.successful_tracks.append(track)
|
||||
except Exception as e:
|
||||
self.failed_tracks.append((track.title, track.artists, str(e)))
|
||||
self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nError: {str(e)}",
|
||||
@@ -304,10 +266,14 @@ class DownloadWorker(QThread):
|
||||
success_message = "Download completed!"
|
||||
if self.failed_tracks:
|
||||
success_message += f"\n\nFailed downloads: {len(self.failed_tracks)} tracks"
|
||||
self.finished.emit(True, success_message, self.failed_tracks)
|
||||
if self.successful_tracks:
|
||||
success_message += f"\n\nSuccessful downloads: {len(self.successful_tracks)} tracks"
|
||||
if self.skipped_tracks:
|
||||
success_message += f"\n\nSkipped (already exists): {len(self.skipped_tracks)} tracks"
|
||||
self.finished.emit(True, success_message, self.failed_tracks, self.successful_tracks, self.skipped_tracks)
|
||||
|
||||
except Exception as e:
|
||||
self.finished.emit(False, str(e), self.failed_tracks)
|
||||
self.finished.emit(False, str(e), self.failed_tracks, self.successful_tracks, self.skipped_tracks)
|
||||
|
||||
def pause(self):
|
||||
self.is_paused = True
|
||||
@@ -363,26 +329,6 @@ class TidalStatusChecker(QThread):
|
||||
self.error.emit(f"Error checking Tidal (API) status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class QobuzStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, region="us", mode="auto"):
|
||||
super().__init__()
|
||||
self.region = region
|
||||
self.mode = mode
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self.mode == "auto":
|
||||
response = requests.get("https://qobuz.squid.wtf", timeout=5)
|
||||
else:
|
||||
response = requests.get(f"https://{self.region}.qqdl.site", timeout=5)
|
||||
self.status_updated.emit(response.status_code == 200)
|
||||
except Exception as e:
|
||||
self.error.emit(f"Error checking Qobuz status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class DeezerStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
error = pyqtSignal(str)
|
||||
@@ -396,19 +342,6 @@ class DeezerStatusChecker(QThread):
|
||||
self.error.emit(f"Error checking Deezer status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class AmazonStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get("https://lucida.to/api/load?url=%2Fapi%2Fcountries%3Fservice%3Damazon", timeout=5)
|
||||
is_online = response.status_code == 200
|
||||
self.status_updated.emit(is_online)
|
||||
except Exception as e:
|
||||
self.error.emit(f"Error checking Amazon Music status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class StatusIndicatorDelegate(QStyledItemDelegate):
|
||||
def paint(self, painter, option, index):
|
||||
item_data = index.data(Qt.ItemDataRole.UserRole)
|
||||
@@ -453,25 +386,14 @@ class ServiceComboBox(QComboBox):
|
||||
|
||||
self.deezer_status_timer = QTimer(self)
|
||||
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
||||
self.deezer_status_timer.start(60000)
|
||||
|
||||
self.amazon_status_checker = AmazonStatusChecker()
|
||||
self.amazon_status_checker.status_updated.connect(self.update_amazon_service_status)
|
||||
self.amazon_status_checker.error.connect(lambda e: print(f"Amazon Music status check error: {e}"))
|
||||
self.amazon_status_checker.start()
|
||||
|
||||
self.amazon_status_timer = QTimer(self)
|
||||
self.amazon_status_timer.timeout.connect(self.refresh_amazon_status)
|
||||
self.amazon_status_timer.start(60000)
|
||||
self.deezer_status_timer.start(60000)
|
||||
|
||||
def setup_items(self):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
self.services = [
|
||||
{'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png', 'online': False},
|
||||
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False},
|
||||
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False},
|
||||
{'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False}
|
||||
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False}
|
||||
]
|
||||
|
||||
for service in self.services:
|
||||
@@ -527,122 +449,16 @@ class ServiceComboBox(QComboBox):
|
||||
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||
self.deezer_status_checker.start()
|
||||
|
||||
def update_amazon_service_status(self, is_online):
|
||||
self.update_service_status('amazon', is_online)
|
||||
|
||||
def refresh_amazon_status(self):
|
||||
if hasattr(self, 'amazon_status_checker') and self.amazon_status_checker.isRunning():
|
||||
self.amazon_status_checker.quit()
|
||||
self.amazon_status_checker.wait()
|
||||
|
||||
self.amazon_status_checker = AmazonStatusChecker()
|
||||
self.amazon_status_checker.status_updated.connect(self.update_amazon_service_status)
|
||||
self.amazon_status_checker.error.connect(lambda e: print(f"Amazon Music status check error: {e}"))
|
||||
self.amazon_status_checker.start()
|
||||
|
||||
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
|
||||
return super().currentData(role)
|
||||
|
||||
def update_qobuz_status(self, region_id, is_online):
|
||||
for i in range(self.count()):
|
||||
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
|
||||
if service_id == 'qobuz':
|
||||
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(service_data, dict):
|
||||
if is_online or service_data.get('online', False):
|
||||
service_data['online'] = True
|
||||
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
|
||||
break
|
||||
|
||||
self.update()
|
||||
|
||||
class QobuzRegionComboBox(QComboBox):
|
||||
status_updated = pyqtSignal(str, bool)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setIconSize(QSize(16, 16))
|
||||
|
||||
self.setItemDelegate(StatusIndicatorDelegate())
|
||||
|
||||
self.setup_items()
|
||||
|
||||
self.status_checkers = {}
|
||||
self.check_status()
|
||||
|
||||
self.status_timer = QTimer(self)
|
||||
self.status_timer.timeout.connect(self.check_status)
|
||||
self.status_timer.start(60000)
|
||||
|
||||
def setup_items(self):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
self.regions = [
|
||||
{'id': 'us', 'name': 'USA', 'icon': 'us.svg', 'online': False},
|
||||
{'id': 'eu', 'name': 'Europe', 'icon': 'eu.svg', 'online': False},
|
||||
{'id': 'br', 'name': 'Brazil', 'icon': 'br.svg', 'online': False},
|
||||
{'id': 'jp', 'name': 'Japan', 'icon': 'jp.svg', 'online': False},
|
||||
{'id': 'au', 'name': 'Australia', 'icon': 'au.svg', 'online': False}
|
||||
]
|
||||
|
||||
for region in self.regions:
|
||||
icon_path = os.path.join(current_dir, region['icon'])
|
||||
if not os.path.exists(icon_path):
|
||||
self.create_placeholder_icon(icon_path)
|
||||
|
||||
icon = QIcon(icon_path)
|
||||
|
||||
self.addItem(icon, region['name'])
|
||||
item_index = self.count() - 1
|
||||
self.setItemData(item_index, region['id'], Qt.ItemDataRole.UserRole + 1)
|
||||
self.setItemData(item_index, region, Qt.ItemDataRole.UserRole)
|
||||
|
||||
def create_placeholder_icon(self, path):
|
||||
pixmap = QPixmap(16, 16)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap.save(path)
|
||||
|
||||
def update_region_status(self, region_id, is_online):
|
||||
for i in range(self.count()):
|
||||
current_region_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
|
||||
if current_region_id == region_id:
|
||||
region_data = self.itemData(i, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(region_data, dict):
|
||||
region_data['online'] = is_online
|
||||
self.setItemData(i, region_data, Qt.ItemDataRole.UserRole)
|
||||
break
|
||||
|
||||
self.update()
|
||||
|
||||
def check_status(self):
|
||||
for region_id, checker in self.status_checkers.items():
|
||||
if checker.isRunning():
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
self.status_checkers.clear()
|
||||
|
||||
for region in self.regions:
|
||||
region_id = region['id']
|
||||
checker = QobuzStatusChecker(region_id, "region")
|
||||
checker.status_updated.connect(lambda status, rid=region_id: self.handle_status_update(rid, status))
|
||||
checker.start()
|
||||
self.status_checkers[region_id] = checker
|
||||
|
||||
def handle_status_update(self, region_id, is_online):
|
||||
self.update_region_status(region_id, is_online)
|
||||
self.status_updated.emit(region_id, is_online)
|
||||
|
||||
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
|
||||
return super().currentData(role)
|
||||
|
||||
class SpotiFLACGUI(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.current_version = "4.6"
|
||||
self.current_version = "4.7"
|
||||
self.tracks = []
|
||||
self.all_tracks = []
|
||||
self.successful_downloads = []
|
||||
self.reset_state()
|
||||
|
||||
self.settings = QSettings('SpotiFLAC', 'Settings')
|
||||
@@ -654,8 +470,6 @@ class SpotiFLACGUI(QWidget):
|
||||
self.use_artist_subfolders = self.settings.value('use_artist_subfolders', False, type=bool)
|
||||
self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool)
|
||||
self.service = self.settings.value('service', 'tidal')
|
||||
self.qobuz_region = self.settings.value('qobuz_region', 'us')
|
||||
self.qobuz_mode = self.settings.value('qobuz_mode', 'auto')
|
||||
self.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
|
||||
self.current_theme_color = self.settings.value('theme_color', '#2196F3')
|
||||
self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration')
|
||||
@@ -1026,9 +840,15 @@ class SpotiFLACGUI(QWidget):
|
||||
self.stop_btn.clicked.connect(self.stop_download)
|
||||
self.pause_resume_btn.clicked.connect(self.toggle_pause_resume)
|
||||
|
||||
self.remove_successful_btn = QPushButton('Remove Finished Songs')
|
||||
self.remove_successful_btn.setFixedWidth(200)
|
||||
self.remove_successful_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.remove_successful_btn.clicked.connect(self.remove_successful_downloads)
|
||||
|
||||
control_layout.addStretch()
|
||||
control_layout.addWidget(self.stop_btn)
|
||||
control_layout.addWidget(self.pause_resume_btn)
|
||||
control_layout.addWidget(self.remove_successful_btn)
|
||||
control_layout.addStretch()
|
||||
|
||||
process_layout.addLayout(control_layout)
|
||||
@@ -1041,6 +861,7 @@ class SpotiFLACGUI(QWidget):
|
||||
self.time_label.hide()
|
||||
self.stop_btn.hide()
|
||||
self.pause_resume_btn.hide()
|
||||
self.remove_successful_btn.hide()
|
||||
|
||||
def setup_settings_tab(self):
|
||||
settings_tab = QWidget()
|
||||
@@ -1211,29 +1032,6 @@ class SpotiFLACGUI(QWidget):
|
||||
service_fallback_layout.addWidget(service_label)
|
||||
service_fallback_layout.addWidget(self.service_dropdown)
|
||||
|
||||
service_fallback_layout.addSpacing(10)
|
||||
|
||||
self.qobuz_mode_label = QLabel('Mode:')
|
||||
self.qobuz_mode_dropdown = QComboBox()
|
||||
self.qobuz_mode_dropdown.addItem("Auto", "auto")
|
||||
self.qobuz_mode_dropdown.addItem("Region", "region")
|
||||
self.qobuz_mode_dropdown.currentIndexChanged.connect(self.on_qobuz_mode_changed)
|
||||
service_fallback_layout.addWidget(self.qobuz_mode_label)
|
||||
service_fallback_layout.addWidget(self.qobuz_mode_dropdown)
|
||||
|
||||
service_fallback_layout.addSpacing(10)
|
||||
|
||||
self.region_label = QLabel('Region:')
|
||||
self.qobuz_region_dropdown = QobuzRegionComboBox()
|
||||
self.qobuz_region_dropdown.currentIndexChanged.connect(self.save_qobuz_region_setting)
|
||||
service_fallback_layout.addWidget(self.region_label)
|
||||
service_fallback_layout.addWidget(self.qobuz_region_dropdown)
|
||||
|
||||
self.qobuz_mode_label.hide()
|
||||
self.qobuz_mode_dropdown.hide()
|
||||
self.region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
|
||||
service_fallback_layout.addStretch()
|
||||
auth_layout.addLayout(service_fallback_layout)
|
||||
|
||||
@@ -1242,17 +1040,9 @@ class SpotiFLACGUI(QWidget):
|
||||
settings_tab.setLayout(settings_layout)
|
||||
self.tab_widget.addTab(settings_tab, "Settings")
|
||||
self.set_combobox_value(self.service_dropdown, self.service)
|
||||
self.set_combobox_value(self.qobuz_region_dropdown, self.qobuz_region)
|
||||
self.set_combobox_value(self.qobuz_mode_dropdown, self.qobuz_mode)
|
||||
self.set_combobox_value(self.track_list_format_dropdown, self.track_list_format)
|
||||
self.set_combobox_value(self.date_format_dropdown, self.date_format)
|
||||
|
||||
self.update_service_ui()
|
||||
|
||||
self.qobuz_region_dropdown.status_updated.connect(
|
||||
lambda region_id, is_online: self.service_dropdown.update_qobuz_status(region_id, is_online)
|
||||
)
|
||||
|
||||
def setup_theme_tab(self):
|
||||
theme_tab = QWidget()
|
||||
theme_layout = QVBoxLayout()
|
||||
@@ -1454,7 +1244,7 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
about_layout.addWidget(section_widget)
|
||||
|
||||
footer_label = QLabel(f"v{self.current_version} | September 2025")
|
||||
footer_label = QLabel(f"v{self.current_version} | October 2025")
|
||||
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
about_tab.setLayout(about_layout)
|
||||
@@ -1465,44 +1255,8 @@ class SpotiFLACGUI(QWidget):
|
||||
self.service = service
|
||||
self.settings.setValue('service', service)
|
||||
self.settings.sync()
|
||||
|
||||
self.update_service_ui()
|
||||
self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}")
|
||||
|
||||
def on_qobuz_mode_changed(self, index):
|
||||
mode = self.qobuz_mode_dropdown.currentData()
|
||||
self.qobuz_mode = mode
|
||||
self.settings.setValue('qobuz_mode', mode)
|
||||
self.settings.sync()
|
||||
|
||||
self.update_qobuz_mode_ui()
|
||||
self.log_output.append(f"Qobuz mode changed to: {self.qobuz_mode_dropdown.currentText()}")
|
||||
|
||||
def update_service_ui(self):
|
||||
service = self.service
|
||||
|
||||
if service == "qobuz":
|
||||
self.qobuz_mode_label.show()
|
||||
self.qobuz_mode_dropdown.show()
|
||||
self.update_qobuz_mode_ui()
|
||||
else:
|
||||
self.qobuz_mode_label.hide()
|
||||
self.qobuz_mode_dropdown.hide()
|
||||
self.region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
|
||||
def update_qobuz_mode_ui(self):
|
||||
mode = self.qobuz_mode_dropdown.currentData()
|
||||
if mode is None:
|
||||
mode = self.qobuz_mode
|
||||
|
||||
if mode == "region":
|
||||
self.region_label.show()
|
||||
self.qobuz_region_dropdown.show()
|
||||
else:
|
||||
self.region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
|
||||
def save_url(self):
|
||||
self.settings.setValue('spotify_url', self.spotify_url.text().strip())
|
||||
self.settings.sync()
|
||||
@@ -1532,13 +1286,6 @@ class SpotiFLACGUI(QWidget):
|
||||
self.settings.setValue('use_album_subfolders', self.use_album_subfolders)
|
||||
self.settings.sync()
|
||||
|
||||
def save_qobuz_region_setting(self):
|
||||
region = self.qobuz_region_dropdown.currentData()
|
||||
self.qobuz_region = region
|
||||
self.settings.setValue('qobuz_region', region)
|
||||
self.settings.sync()
|
||||
self.log_output.append(f"Qobuz region setting saved: {self.qobuz_region_dropdown.currentText()}")
|
||||
|
||||
def save_track_list_format(self):
|
||||
format_value = self.track_list_format_dropdown.currentData()
|
||||
self.track_list_format = format_value
|
||||
@@ -1935,8 +1682,6 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
def start_download_worker(self, tracks_to_download, outpath):
|
||||
service = self.service_dropdown.currentData()
|
||||
qobuz_region = self.qobuz_region_dropdown.currentData() if service == "qobuz" else "us"
|
||||
qobuz_mode = self.qobuz_mode_dropdown.currentData() if service == "qobuz" else "auto"
|
||||
|
||||
self.worker = DownloadWorker(
|
||||
tracks_to_download,
|
||||
@@ -1949,11 +1694,9 @@ class SpotiFLACGUI(QWidget):
|
||||
self.use_track_numbers,
|
||||
self.use_artist_subfolders,
|
||||
self.use_album_subfolders,
|
||||
service,
|
||||
qobuz_region,
|
||||
qobuz_mode
|
||||
service
|
||||
)
|
||||
self.worker.finished.connect(self.on_download_finished)
|
||||
self.worker.finished.connect(lambda success, message, failed_tracks, successful_tracks, skipped_tracks: self.on_download_finished(success, message, failed_tracks, successful_tracks, skipped_tracks))
|
||||
self.worker.progress.connect(self.update_progress)
|
||||
self.worker.start()
|
||||
self.start_timer()
|
||||
@@ -1985,15 +1728,25 @@ class SpotiFLACGUI(QWidget):
|
||||
if hasattr(self, 'worker'):
|
||||
self.worker.stop()
|
||||
self.stop_timer()
|
||||
self.on_download_finished(True, "Download stopped by user.", [])
|
||||
self.on_download_finished(True, "Download stopped by user.", [], [], [])
|
||||
|
||||
def on_download_finished(self, success, message, failed_tracks):
|
||||
def on_download_finished(self, success, message, failed_tracks, successful_tracks=None, skipped_tracks=None):
|
||||
self.progress_bar.hide()
|
||||
self.stop_btn.hide()
|
||||
self.pause_resume_btn.hide()
|
||||
self.pause_resume_btn.setText('Pause')
|
||||
self.stop_timer()
|
||||
|
||||
if successful_tracks is not None:
|
||||
self.successful_downloads = successful_tracks
|
||||
if skipped_tracks is not None:
|
||||
self.skipped_downloads = skipped_tracks
|
||||
|
||||
if (hasattr(self, 'successful_downloads') and self.successful_downloads) or (hasattr(self, 'skipped_downloads') and self.skipped_downloads):
|
||||
self.remove_successful_btn.show()
|
||||
else:
|
||||
self.remove_successful_btn.hide()
|
||||
|
||||
self.download_selected_btn.setEnabled(True)
|
||||
self.download_all_btn.setEnabled(True)
|
||||
|
||||
@@ -2024,6 +1777,59 @@ class SpotiFLACGUI(QWidget):
|
||||
self.worker.pause()
|
||||
self.pause_resume_btn.setText('Resume')
|
||||
|
||||
def remove_successful_downloads(self):
|
||||
successful_tracks = getattr(self, 'successful_downloads', [])
|
||||
skipped_tracks = getattr(self, 'skipped_downloads', [])
|
||||
|
||||
if not successful_tracks and not skipped_tracks:
|
||||
self.log_output.append("No downloaded or skipped tracks to remove.")
|
||||
return
|
||||
|
||||
tracks_to_remove = []
|
||||
|
||||
for track in self.tracks:
|
||||
for successful_track in successful_tracks:
|
||||
if (track.title == successful_track.title and
|
||||
track.artists == successful_track.artists and
|
||||
track.album == successful_track.album):
|
||||
tracks_to_remove.append(track)
|
||||
break
|
||||
|
||||
for track in self.tracks:
|
||||
for skipped_track in skipped_tracks:
|
||||
if (track.title == skipped_track.title and
|
||||
track.artists == skipped_track.artists and
|
||||
track.album == skipped_track.album):
|
||||
if track not in tracks_to_remove:
|
||||
tracks_to_remove.append(track)
|
||||
break
|
||||
|
||||
if tracks_to_remove:
|
||||
for track in tracks_to_remove:
|
||||
if track in self.tracks:
|
||||
self.tracks.remove(track)
|
||||
if track in self.all_tracks:
|
||||
self.all_tracks.remove(track)
|
||||
|
||||
self.update_track_list_display()
|
||||
successful_count = len([t for t in tracks_to_remove if t in successful_tracks])
|
||||
skipped_count = len([t for t in tracks_to_remove if t in skipped_tracks])
|
||||
|
||||
message = f"Removed {len(tracks_to_remove)} tracks from the list"
|
||||
if successful_count > 0:
|
||||
message += f" ({successful_count} downloaded"
|
||||
if skipped_count > 0:
|
||||
message += f", {skipped_count} already existed" if successful_count > 0 else f" ({skipped_count} already existed"
|
||||
if successful_count > 0 or skipped_count > 0:
|
||||
message += ")"
|
||||
|
||||
self.log_output.append(message + ".")
|
||||
self.tab_widget.setCurrentIndex(0)
|
||||
else:
|
||||
self.log_output.append("No matching tracks found in the current list.")
|
||||
|
||||
self.remove_successful_btn.hide()
|
||||
|
||||
def remove_selected_tracks(self):
|
||||
if not self.is_single_track:
|
||||
selected_items = self.track_list.selectedItems()
|
||||
@@ -2068,12 +1874,6 @@ class SpotiFLACGUI(QWidget):
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
|
||||
if hasattr(self, 'qobuz_region_dropdown'):
|
||||
for checker in self.qobuz_region_dropdown.status_checkers.values():
|
||||
if checker.isRunning():
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
|
||||
if hasattr(self, 'worker') and self.worker and self.worker.isRunning():
|
||||
self.worker.stop()
|
||||
self.worker.quit()
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
-128
@@ -1,128 +0,0 @@
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
import urllib3
|
||||
from urllib.parse import unquote
|
||||
from random import randrange
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
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)}"
|
||||
|
||||
def extract_data(html, patterns):
|
||||
for pattern in patterns:
|
||||
if match := re.search(pattern, html):
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def download_track(track_id, service="amazon", output_dir="."):
|
||||
client = requests.Session()
|
||||
client.verify = False
|
||||
headers = {'User-Agent': get_random_user_agent()}
|
||||
|
||||
try:
|
||||
spotify_url = f"https://open.spotify.com/track/{track_id}"
|
||||
params = {"url": spotify_url, "country": "auto", "to": service}
|
||||
|
||||
response = client.get("https://lucida.to", params=params, headers=headers, timeout=30)
|
||||
html = response.text
|
||||
|
||||
token = extract_data(html, [r'token:"([^"]+)"', r'"token"\s*:\s*"([^"]+)"'])
|
||||
url = extract_data(html, [r'"url":"([^"]+)"', r'url:"([^"]+)"'])
|
||||
expiry = extract_data(html, [r'tokenExpiry:(\d+)', r'"tokenExpiry"\s*:\s*(\d+)'])
|
||||
|
||||
if not (token and url):
|
||||
raise Exception("Could not extract required data")
|
||||
|
||||
try:
|
||||
decoded_token = base64.b64decode(base64.b64decode(token).decode('latin1')).decode('latin1')
|
||||
except:
|
||||
decoded_token = token
|
||||
|
||||
clean_url = url.replace('\\/', '/')
|
||||
print(f"Fetching: {clean_url}")
|
||||
|
||||
request_data = {
|
||||
"account": {"id": "auto", "type": "country"},
|
||||
"compat": "false", "downscale": "original", "handoff": True,
|
||||
"metadata": True, "private": True,
|
||||
"token": {"primary": decoded_token, "expiry": int(expiry) if expiry else None},
|
||||
"upload": {"enabled": False, "service": "pixeldrain"},
|
||||
"url": clean_url
|
||||
}
|
||||
|
||||
response = client.post("https://lucida.to/api/load?url=/api/fetch/stream/v2",
|
||||
json=request_data, headers=headers)
|
||||
|
||||
if csrf_token := response.cookies.get('csrf_token'):
|
||||
headers['X-CSRF-Token'] = csrf_token
|
||||
|
||||
data = response.json()
|
||||
if not data.get("success"):
|
||||
raise Exception(f"Request failed: {data.get('error', 'Unknown error')}")
|
||||
|
||||
completion_url = f"https://{data['server']}.lucida.to/api/fetch/request/{data['handoff']}"
|
||||
print("Fetching URL...")
|
||||
|
||||
while True:
|
||||
resp = client.get(completion_url, headers=headers).json()
|
||||
if resp["status"] == "completed":
|
||||
print("URL found")
|
||||
break
|
||||
elif resp["status"] == "error":
|
||||
raise Exception(f"Processing failed: {resp.get('message', 'Unknown error')}")
|
||||
elif progress := resp.get("progress"):
|
||||
percent = int((progress.get("current", 0) / progress.get("total", 100)) * 100)
|
||||
print(f"\r{percent}%", end="")
|
||||
time.sleep(1)
|
||||
|
||||
download_url = f"https://{data['server']}.lucida.to/api/fetch/request/{data['handoff']}/download"
|
||||
response = client.get(download_url, stream=True, headers=headers)
|
||||
|
||||
file_name = "track.flac"
|
||||
if content_disp := response.headers.get('content-disposition'):
|
||||
if match := re.search(r'filename[*]?=([^;]+)', content_disp):
|
||||
raw_name = match.group(1).strip('"\'')
|
||||
file_name = unquote(raw_name[7:] if raw_name.startswith("UTF-8''") else raw_name)
|
||||
for char in '<>:"/\\|?*':
|
||||
file_name = file_name.replace(char, '')
|
||||
file_name = file_name.strip()
|
||||
|
||||
file_path = os.path.join(output_dir, file_name)
|
||||
print(f"Downloading...")
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
print("Download complete")
|
||||
print("Done")
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return None
|
||||
|
||||
class LucidaDownloader:
|
||||
def __init__(self):
|
||||
self.progress_callback = None
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
def download(self, track_id, output_dir, is_paused_callback=None, is_stopped_callback=None):
|
||||
try:
|
||||
return download_track(track_id, service="amazon", output_dir=output_dir)
|
||||
except Exception as e:
|
||||
raise Exception(f"Amazon Music download failed: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== AmazonDL - Amazon Music Downloader ===")
|
||||
track_id = "2plbrEY59IikOBgBGLjaoe"
|
||||
service = "amazon"
|
||||
|
||||
download_track(track_id, service)
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-au" viewBox="0 0 640 480">
|
||||
<path fill="#00008B" d="M0 0h640v480H0z"/>
|
||||
<path fill="#fff" d="m37.5 0 122 90.5L281 0h39v31l-120 89.5 120 89V240h-40l-120-89.5L40.5 240H0v-30l119.5-89L0 32V0z"/>
|
||||
<path fill="red" d="M212 140.5 320 220v20l-135.5-99.5zm-92 10 3 17.5-96 72H0zM320 0v1.5l-124.5 94 1-22L295 0zM0 0l119.5 88h-30L0 21z"/>
|
||||
<path fill="#fff" d="M120.5 0v240h80V0zM0 80v80h320V80z"/>
|
||||
<path fill="red" d="M0 96.5v48h320v-48zM136.5 0v240h48V0z"/>
|
||||
<path fill="#fff" d="m527 396.7-20.5 2.6 2.2 20.5-14.8-14.4-14.7 14.5 2-20.5-20.5-2.4 17.3-11.2-10.9-17.5 19.6 6.5 6.9-19.5 7.1 19.4 19.5-6.7-10.7 17.6zm-3.7-117.2 2.7-13-9.8-9 13.2-1.5 5.5-12.1 5.5 12.1 13.2 1.5-9.8 9 2.7 13-11.6-6.6zm-104.1-60-20.3 2.2 1.8 20.3-14.4-14.5-14.8 14.1 2.4-20.3-20.2-2.7 17.3-10.8-10.5-17.5 19.3 6.8L387 178l6.7 19.3 19.4-6.3-10.9 17.3 17.1 11.2ZM623 186.7l-20.9 2.7 2.3 20.9-15.1-14.7-15 14.8 2.1-21-20.9-2.4 17.7-11.5-11.1-17.9 20 6.7 7-19.8 7.2 19.8 19.9-6.9-11 18zm-96.1-83.5-20.7 2.3 1.9 20.8-14.7-14.8-15.1 14.4 2.4-20.7-20.7-2.8 17.7-11L467 73.5l19.7 6.9 7.3-19.5 6.8 19.7 19.8-6.5-11.1 17.6zM234 385.7l-45.8 5.4 4.6 45.9-32.8-32.4-33 32.2 4.9-45.9-45.8-5.8 38.9-24.8-24-39.4 43.6 15 15.8-43.4 15.5 43.5 43.7-14.7-24.3 39.2 38.8 25.1Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,45 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-br" viewBox="0 0 640 480">
|
||||
<g stroke-width="1pt">
|
||||
<path fill="#229e45" fill-rule="evenodd" d="M0 0h640v480H0z"/>
|
||||
<path fill="#f8e509" fill-rule="evenodd" d="m321.4 436 301.5-195.7L319.6 44 17.1 240.7z"/>
|
||||
<path fill="#2b49a3" fill-rule="evenodd" d="M452.8 240c0 70.3-57.1 127.3-127.6 127.3A127.4 127.4 0 1 1 452.8 240"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m283.3 316.3-4-2.3-4 2 .9-4.5-3.2-3.4 4.5-.5 2.2-4 1.9 4.2 4.4.8-3.3 3m86 26.3-3.9-2.3-4 2 .8-4.5-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.1m-36.2-30-3.4-2-3.5 1.8.8-3.9-2.8-2.9 4-.4 1.8-3.6 1.6 3.7 3.9.7-3 2.7m87-8.5-3.4-2-3.5 1.8.8-3.9-2.7-2.8 3.9-.4 1.8-3.5 1.6 3.6 3.8.7-2.9 2.6m-87.3-22-4-2.2-4 2 .8-4.6-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.2m-104.6-35-4-2.2-4 2 1-4.6-3.3-3.3 4.6-.5 2-4.1 2 4.2 4.4.8-3.3 3.1m13.3 57.2-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.1-4 2 4.2 4.4.8-3.3 3.1m132-67.3-3.6-2-3.6 1.8.8-4-2.8-3 4-.5 1.9-3.6 1.7 3.8 4 .7-3 2.7m-6.7 38.3-2.7-1.6-2.9 1.4.6-3.2-2.2-2.3 3.2-.4 1.5-2.8 1.3 3 3 .5-2.2 2.2m-142.2 50.4-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2M419 299.8l-2.2-1.1-2.2 1 .5-2.3-1.7-1.6 2.4-.3 1.2-2 1 2 2.5.5-1.9 1.5"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m42.3 3-2.6-1.4-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .5-2.3 2.1m-4.8 17-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m87.4-22.2-2.6-1.6-2.8 1.4.6-3-2-2.3 3-.3 1.4-2.7 1.2 2.8 3 .5-2.2 2.1m-25.1 3-2.7-1.5-2.7 1.4.6-3-2-2.3 3-.3 1.4-2.8 1.2 2.9 3 .5-2.2 2.1m-68.8-5.8-1.7-1-1.7.8.4-1.9-1.3-1.4 1.9-.2.8-1.7.8 1.8 1.9.3-1.4 1.3m167.8 45.4-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m-20.8 6-2.2-1.4-2.3 1.2.5-2.6-1.7-1.8 2.5-.3 1.2-2.3 1 2.4 2.5.4-1.9 1.8m10.4 2.3-2-1.2-2.1 1 .4-2.3-1.6-1.7 2.3-.3 1.1-2 1 2 2.3.5-1.7 1.6m29.1-22.8-2-1-2 1 .5-2.3-1.6-1.7 2.3-.3 1-2 1 2.1 2.1.4-1.6 1.6m-38.8 41.8-2.5-1.4-2.7 1.2.6-2.8-2-2 3-.3 1.3-2.5 1.2 2.6 3 .5-2.3 1.9m.6 14.2-2.4-1.4-2.4 1.3.6-2.8-1.9-2 2.7-.4 1.2-2.5 1.1 2.6 2.7.5-2 2m-19-23.1-1.9-1.2-2 1 .4-2.2-1.5-1.7 2.2-.2 1-2 1 2 2.2.4-1.6 1.6m-17.8 2.3-2-1.2-2 1 .5-2.2-1.6-1.7 2.3-.2 1-2 1 2 2.1.4-1.6 1.6m-30.4-24.6-2-1.1-2 1 .5-2.3-1.6-1.6 2.2-.3 1-2 1 2 2.2.5-1.6 1.5m3.7 57-1.6-.9-1.8.9.4-2-1.3-1.4 1.9-.2.9-1.7.8 1.8 1.9.3-1.4 1.3m-46.2-86.6-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.2-4 1.9 4.2 4.4.8-3.3 3.1"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M444.4 285.8a125 125 0 0 0 5.8-19.8c-67.8-59.5-143.3-90-238.7-83.7a125 125 0 0 0-8.5 20.9c113-10.8 196 39.2 241.4 82.6"/>
|
||||
<path fill="#309e3a" d="m414 252.4 2.3 1.3a3 3 0 0 0-.3 2.2 3 3 0 0 0 1.4 1.7q1 .8 2 .7.9 0 1.3-.7l.2-.9-.5-1-1.5-1.8a8 8 0 0 1-1.8-3 4 4 0 0 1 2-4.4 4 4 0 0 1 2.3-.2 7 7 0 0 1 2.6 1.2q2.1 1.5 2.6 3.2a4 4 0 0 1-.6 3.3l-2.4-1.5q.5-1 .2-1.7-.2-.8-1.2-1.4a3 3 0 0 0-1.8-.7 1 1 0 0 0-.9.5q-.3.4-.1 1 .2.8 1.6 2.2t2 2.5a4 4 0 0 1-.3 4.2 4 4 0 0 1-1.9 1.5 4 4 0 0 1-2.4.3q-1.3-.3-2.8-1.3-2.2-1.5-2.7-3.3a5 5 0 0 1 .6-4zm-11.6-7.6 2.5 1.3a3 3 0 0 0-.2 2.2 3 3 0 0 0 1.4 1.6q1.1.8 2 .6.9 0 1.3-.8l.2-.8q0-.5-.5-1l-1.6-1.8q-1.7-1.6-2-2.8a4 4 0 0 1 .4-3.1 4 4 0 0 1 1.6-1.4 4 4 0 0 1 2.2-.3 7 7 0 0 1 2.6 1q2.3 1.5 2.7 3.1a4 4 0 0 1-.4 3.4l-2.5-1.4q.5-1 .2-1.7-.4-1-1.3-1.4a3 3 0 0 0-1.9-.6 1 1 0 0 0-.8.5q-.3.4-.1 1 .3.8 1.7 2.2 1.5 1.5 2 2.4a4 4 0 0 1 0 4.2 4 4 0 0 1-1.8 1.6 4 4 0 0 1-2.4.3 8 8 0 0 1-2.9-1.1 6 6 0 0 1-2.8-3.2 5 5 0 0 1 .4-4m-14.2-3.8 7.3-12 8.8 5.5-1.2 2-6.4-4-1.6 2.7 6 3.7-1.3 2-6-3.7-2 3.3 6.7 4-1.2 2zm-20.7-17 1.1-2 5.4 2.7-2.5 5q-1.2.3-3 .2a9 9 0 0 1-3.3-1 8 8 0 0 1-3-2.6 6 6 0 0 1-1-3.5 9 9 0 0 1 1-3.7 8 8 0 0 1 2.6-3 6 6 0 0 1 3.6-1.1q1.4 0 3.2 1 2.4 1.1 3.1 2.8a5 5 0 0 1 .3 3.5l-2.7-.8a3 3 0 0 0-.2-2q-.4-.9-1.6-1.4a4 4 0 0 0-3.1-.3q-1.5.5-2.6 2.6t-.7 3.8a4 4 0 0 0 2 2.4q.8.5 1.7.5h1.8l.8-1.6zm-90.2-22.3 2-14 4.2.7 1.1 9.8 3.9-9 4.2.6-2 13.8-2.7-.4 1.7-10.9-4.4 10.5-2.7-.4-1.1-11.3-1.6 11zm-14.1-1.7 1.3-14 10.3 1-.2 2.4-7.5-.7-.3 3 7 .7-.3 2.4-7-.7-.3 3.8 7.8.7-.2 2.4z"/>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M216.5 191.3q0-2.2.7-3.6a7 7 0 0 1 1.4-1.9 5 5 0 0 1 1.8-1.2q1.5-.5 3-.5 3.1.1 5 2a7 7 0 0 1 1.6 5.5q0 3.3-2 5.3a7 7 0 0 1-5 1.7 7 7 0 0 1-4.8-2 7 7 0 0 1-1.7-5.3"/>
|
||||
<path fill="#f7ffff" d="M219.4 191.3q0 2.3 1 3.6t2.8 1.3a4 4 0 0 0 2.8-1.1q1-1.2 1.1-3.7.1-2.4-1-3.6a4 4 0 0 0-2.7-1.3 4 4 0 0 0-2.8 1.2q-1.1 1.2-1.2 3.6"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m233 198.5.2-14h6q2.2 0 3.2.5 1 .3 1.6 1.3c.6 1 .6 1.4.6 2.3a4 4 0 0 1-1 2.6 5 5 0 0 1-2.7 1.2l1.5 1.2q.6.6 1.5 2.3l1.7 2.8h-3.4l-2-3.2-1.4-2-.9-.6-1.4-.2h-.6v5.8z"/>
|
||||
<path fill="#fff" d="M236 190.5h2q2.1 0 2.6-.2.5-.1.8-.5.4-.6.3-1 0-.9-.4-1.2-.3-.4-1-.6h-2l-2.3-.1z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m249 185.2 5.2.3q1.7 0 2.6.3a5 5 0 0 1 2 1.4 6 6 0 0 1 1.2 2.4q.4 1.4.3 3.3a9 9 0 0 1-.5 3q-.6 1.5-1.7 2.4a5 5 0 0 1-2 1q-1 .3-2.5.2l-5.3-.3z"/>
|
||||
<path fill="#fff" d="m251.7 187.7-.5 9.3h3.8q.8 0 1.2-.5.5-.4.8-1.3t.4-2.6l-.1-2.5a3 3 0 0 0-.8-1.4l-1.2-.7-2.3-.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m317.6 210.2 3.3-13.6 4.4 1 3.2 1q1.1.6 1.6 1.9t.2 2.8q-.3 1.2-1 2a4 4 0 0 1-3 1.4q-1 0-3-.5l-1.7-.5-1.2 5.2z"/>
|
||||
<path fill="#fff" d="m323 199.6-.8 3.8 1.5.4q1.6.4 2.2.3a2 2 0 0 0 1.6-1.5q0-.7-.2-1.3a2 2 0 0 0-1-.9l-1.9-.5-1.3-.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m330.6 214.1 4.7-13.2 5.5 2q2.2.8 3 1.4.8.7 1 1.8c.2 1.1.2 1.5 0 2.3q-.6 1.5-1.8 2.2-1.2.6-3 .3.6.7 1 1.6l.8 2.7.6 3.1-3.1-1.1-1-3.6-.7-2.4-.6-.8q-.3-.4-1.3-.7l-.5-.2-2 5.6z"/>
|
||||
<path fill="#fff" d="m336 207.4 1.9.7q2 .7 2.5.7t.9-.3q.5-.3.6-.9.3-.6 0-1.2l-.8-.9-2-.7-2-.7-1.2 3.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M347 213.6a9 9 0 0 1 1.7-3.2l1.8-1.5 2-.7q1.5-.1 3.1.4a7 7 0 0 1 4.2 3.3q1.2 2.4.2 5.7a7 7 0 0 1-3.4 4.5q-2.3 1.3-5.2.4a7 7 0 0 1-4.2-3.3 7 7 0 0 1-.2-5.6"/>
|
||||
<path fill="#fff" d="M349.8 214.4q-.7 2.3 0 3.8c.7 1.5 1.2 1.6 2.3 2q1.5.5 3-.4 1.4-.8 2.1-3.2.8-2.2 0-3.7a4 4 0 0 0-2.2-2 4 4 0 0 0-3 .3q-1.5.8-2.2 3.2"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m374.3 233.1 6.4-12.4 5.3 2.7a10 10 0 0 1 2.7 1.9q.8.7.8 1.9c0 1.2 0 1.5-.4 2.2a4 4 0 0 1-2 2q-1.5.4-3.1-.2.6 1 .8 1.7.3.9.4 2.8l.2 3.2-3-1.5-.4-3.7-.3-2.5-.5-1-1.2-.7-.5-.3-2.7 5.2z"/>
|
||||
<path fill="#fff" d="m380.5 227.2 1.9 1q1.8 1 2.3 1t1-.2q.4-.2.7-.8t.2-1.2l-.7-1-1.8-1-2-1z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M426.1 258.7a9 9 0 0 1 2.5-2.6 7 7 0 0 1 2.2-.9 6 6 0 0 1 2.2 0q1.5.3 2.8 1.2a7 7 0 0 1 3 4.4q.4 2.6-1.4 5.5a7 7 0 0 1-4.5 3.3 7 7 0 0 1-5.2-1.1 7 7 0 0 1-3-4.4q-.4-2.7 1.4-5.4"/>
|
||||
<path fill="#fff" d="M428.6 260.3q-1.4 2-1.1 3.6a4 4 0 0 0 1.6 2.5q1.5 1 3 .6t2.9-2.4q1.4-2.1 1.1-3.6t-1.6-2.6c-1.4-1.1-2-.8-3-.5q-1.5.3-3 2.4z"/>
|
||||
</g>
|
||||
<path fill="#309e3a" d="m301.8 204.5 2.3-9.8 7.2 1.7-.3 1.6-5.3-1.2-.5 2.2 4.9 1.1-.4 1.7-4.9-1.2-.6 2.7 5.5 1.3-.4 1.6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.0 KiB |
@@ -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 |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-jp" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="jp-a">
|
||||
<path fill-opacity=".7" d="M-88 32h640v480H-88z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g fill-rule="evenodd" stroke-width="1pt" clip-path="url(#jp-a)" transform="translate(88 -32)">
|
||||
<path fill="#fff" d="M-128 32h720v480h-720z"/>
|
||||
<circle cx="523.1" cy="344.1" r="194.9" fill="#bc002d" transform="translate(-168.4 8.6)scale(.76554)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 470 B |
-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
|
||||
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 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, timeout=30):
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.headers = {
|
||||
'User-Agent': get_random_user_agent()
|
||||
}
|
||||
self.base_api_url = "https://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, 'region': 'auto'}
|
||||
|
||||
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, 'region': 'auto'}
|
||||
|
||||
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 (Auto) ===")
|
||||
downloader = QobuzDownloader()
|
||||
|
||||
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()
|
||||
@@ -1,255 +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
|
||||
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 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 ["us", "eu", "br", "jp", "au"]:
|
||||
raise ValueError("Region must be one of: 'us', 'eu', 'br', 'jp', 'au'")
|
||||
|
||||
self.region = region
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.headers = {
|
||||
'User-Agent': get_random_user_agent()
|
||||
}
|
||||
self.base_api_url = f"https://{region}.qqdl.site/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 (Region) ===")
|
||||
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,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.5"
|
||||
"version": "4.6"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user