Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bddeab0d1 | |||
| 03a30ee09a | |||
| 2d908e2f75 | |||
| e8f7bf7313 | |||
| 1f0922f358 | |||
| 3f267a3fa1 | |||
| 22da74a027 |
@@ -3,10 +3,16 @@
|
|||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz & Tidal.
|
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal & Deezer.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v3.8/SpotiFLAC.exe)
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.0/SpotiFLAC.exe)
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
> [!Important]
|
||||||
|
> - Requires **Google Chrome, Chromium, Microsoft Edge,** or **Brave** to use `Deezer`
|
||||||
|
> - If after **Cloudflare** verification nothing happens, use a `VPN`, your country is likely blocked by `corsproxy.io`
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
|||||||
+190
-53
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import requests
|
import requests
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
from packaging import version
|
from packaging import version
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
@@ -19,6 +20,7 @@ from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkRepl
|
|||||||
from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
|
from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
|
||||||
from qobuzDL import QobuzDownloader
|
from qobuzDL import QobuzDownloader
|
||||||
from tidalDL import TidalDownloader
|
from tidalDL import TidalDownloader
|
||||||
|
from deezerDL import DeezerDownloader
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Track:
|
class Track:
|
||||||
@@ -88,6 +90,8 @@ class DownloadWorker(QThread):
|
|||||||
downloader = QobuzDownloader(self.qobuz_region)
|
downloader = QobuzDownloader(self.qobuz_region)
|
||||||
elif self.service == "tidal":
|
elif self.service == "tidal":
|
||||||
downloader = TidalDownloader()
|
downloader = TidalDownloader()
|
||||||
|
elif self.service == "deezer":
|
||||||
|
downloader = DeezerDownloader()
|
||||||
else:
|
else:
|
||||||
downloader = TidalDownloader()
|
downloader = TidalDownloader()
|
||||||
|
|
||||||
@@ -162,8 +166,6 @@ class DownloadWorker(QThread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self.progress.emit(f"Searching and downloading from Tidal for ISRC: {track.isrc} - {track.title} - {track.artists}", 0)
|
self.progress.emit(f"Searching and downloading from Tidal for ISRC: {track.isrc} - {track.title} - {track.artists}", 0)
|
||||||
|
|
||||||
import asyncio
|
|
||||||
is_paused_callback = lambda: self.is_paused
|
is_paused_callback = lambda: self.is_paused
|
||||||
is_stopped_callback = lambda: self.is_stopped
|
is_stopped_callback = lambda: self.is_stopped
|
||||||
|
|
||||||
@@ -198,12 +200,44 @@ class DownloadWorker(QThread):
|
|||||||
else:
|
else:
|
||||||
downloaded_file = None
|
downloaded_file = None
|
||||||
raise Exception(f"Tidal download failed or returned unexpected result: {download_result_details}")
|
raise Exception(f"Tidal download failed or returned unexpected result: {download_result_details}")
|
||||||
|
elif self.service == "deezer":
|
||||||
|
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"Downloading from Deezer with ISRC: {track.isrc}", 0)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
success = loop.run_until_complete(downloader.download_by_isrc(track.isrc, track_outpath))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
safe_title = "".join(c for c in track.title if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||||
|
safe_artist = "".join(c for c in track.artists if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||||
|
expected_filename = f"{safe_artist} - {safe_title}.flac"
|
||||||
|
downloaded_file = os.path.join(track_outpath, expected_filename)
|
||||||
|
|
||||||
|
if not os.path.exists(downloaded_file):
|
||||||
|
import glob
|
||||||
|
flac_files = glob.glob(os.path.join(track_outpath, "*.flac"))
|
||||||
|
if flac_files:
|
||||||
|
downloaded_file = max(flac_files, key=os.path.getctime)
|
||||||
|
else:
|
||||||
|
raise Exception("Downloaded file not found")
|
||||||
|
else:
|
||||||
|
raise Exception("Deezer download failed")
|
||||||
else:
|
else:
|
||||||
track_id = track.id
|
track_id = track.id
|
||||||
self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0)
|
self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0)
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
if loop.is_closed():
|
if loop.is_closed():
|
||||||
@@ -225,21 +259,25 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.is_stopped:
|
if self.is_stopped:
|
||||||
return
|
return
|
||||||
|
|
||||||
if downloaded_file == new_filepath:
|
if downloaded_file and os.path.exists(downloaded_file):
|
||||||
self.progress.emit(f"File already exists: {new_filename}", 0)
|
if downloaded_file == new_filepath:
|
||||||
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
|
self.progress.emit(f"File already exists: {new_filename}", 0)
|
||||||
int((i + 1) / total_tracks * 100))
|
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
|
||||||
continue
|
int((i + 1) / total_tracks * 100))
|
||||||
|
continue
|
||||||
|
|
||||||
if os.path.exists(downloaded_file) and downloaded_file != new_filepath:
|
if downloaded_file != new_filepath:
|
||||||
if os.path.exists(new_filepath):
|
try:
|
||||||
os.remove(new_filepath)
|
os.rename(downloaded_file, new_filepath)
|
||||||
os.rename(downloaded_file, new_filepath)
|
self.progress.emit(f"File renamed to: {new_filename}", 0)
|
||||||
self.progress.emit(f"File renamed to: {new_filename}", 0)
|
except OSError as e:
|
||||||
|
self.progress.emit(f"Warning: Could not rename file {downloaded_file} to {new_filepath}: {str(e)}", 0)
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise Exception(f"Download failed or file not found: {downloaded_file}")
|
||||||
|
|
||||||
self.progress.emit(f"Successfully downloaded: {track.title} - {track.artists}",
|
self.progress.emit(f"Successfully downloaded: {track.title} - {track.artists}",
|
||||||
int((i + 1) / total_tracks * 100))
|
int((i + 1) / total_tracks * 100))
|
||||||
@@ -336,6 +374,19 @@ class QobuzStatusChecker(QThread):
|
|||||||
self.error.emit(f"Error checking Qobuz status: {str(e)}")
|
self.error.emit(f"Error checking Qobuz status: {str(e)}")
|
||||||
self.status_updated.emit(False)
|
self.status_updated.emit(False)
|
||||||
|
|
||||||
|
class DeezerStatusChecker(QThread):
|
||||||
|
status_updated = pyqtSignal(bool)
|
||||||
|
error = pyqtSignal(str)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
response = requests.get("https://deezmate.com/", timeout=5)
|
||||||
|
is_online = response.status_code == 200
|
||||||
|
self.status_updated.emit(is_online)
|
||||||
|
except Exception as e:
|
||||||
|
self.error.emit(f"Error checking Deezer status: {str(e)}")
|
||||||
|
self.status_updated.emit(False)
|
||||||
|
|
||||||
class StatusIndicatorDelegate(QStyledItemDelegate):
|
class StatusIndicatorDelegate(QStyledItemDelegate):
|
||||||
def paint(self, painter, option, index):
|
def paint(self, painter, option, index):
|
||||||
item_data = index.data(Qt.ItemDataRole.UserRole)
|
item_data = index.data(Qt.ItemDataRole.UserRole)
|
||||||
@@ -373,12 +424,22 @@ class ServiceComboBox(QComboBox):
|
|||||||
self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
|
self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
|
||||||
self.tidal_status_timer.start(6000)
|
self.tidal_status_timer.start(6000)
|
||||||
|
|
||||||
|
self.deezer_status_checker = DeezerStatusChecker()
|
||||||
|
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status)
|
||||||
|
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||||
|
self.deezer_status_checker.start()
|
||||||
|
|
||||||
|
self.deezer_status_timer = QTimer(self)
|
||||||
|
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
||||||
|
self.deezer_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__))
|
||||||
|
|
||||||
self.services = [
|
self.services = [
|
||||||
{'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png', 'online': False},
|
{'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png', 'online': False},
|
||||||
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False}
|
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False},
|
||||||
|
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False}
|
||||||
]
|
]
|
||||||
|
|
||||||
for service in self.services:
|
for service in self.services:
|
||||||
@@ -414,6 +475,23 @@ class ServiceComboBox(QComboBox):
|
|||||||
self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
|
self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
|
||||||
self.tidal_status_checker.start()
|
self.tidal_status_checker.start()
|
||||||
|
|
||||||
|
def update_deezer_service_status(self, is_online):
|
||||||
|
for i in range(self.count()):
|
||||||
|
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||||
|
if service_id == 'deezer':
|
||||||
|
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_deezer_status(self):
|
||||||
|
self.deezer_status_checker = DeezerStatusChecker()
|
||||||
|
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status)
|
||||||
|
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||||
|
self.deezer_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)
|
||||||
|
|
||||||
@@ -505,8 +583,9 @@ class QobuzRegionComboBox(QComboBox):
|
|||||||
class SpotiFLACGUI(QWidget):
|
class SpotiFLACGUI(QWidget):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.current_version = "3.9"
|
self.current_version = "4.1"
|
||||||
self.tracks = []
|
self.tracks = []
|
||||||
|
self.all_tracks = []
|
||||||
self.reset_state()
|
self.reset_state()
|
||||||
|
|
||||||
self.settings = QSettings('SpotiFLAC', 'Settings')
|
self.settings = QSettings('SpotiFLAC', 'Settings')
|
||||||
@@ -560,6 +639,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
|
|
||||||
def reset_state(self):
|
def reset_state(self):
|
||||||
self.tracks.clear()
|
self.tracks.clear()
|
||||||
|
self.all_tracks.clear()
|
||||||
self.is_album = False
|
self.is_album = False
|
||||||
self.is_playlist = False
|
self.is_playlist = False
|
||||||
self.is_single_track = False
|
self.is_single_track = False
|
||||||
@@ -575,11 +655,15 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.pause_resume_btn.setText('Pause')
|
self.pause_resume_btn.setText('Pause')
|
||||||
self.reset_info_widget()
|
self.reset_info_widget()
|
||||||
self.hide_track_buttons()
|
self.hide_track_buttons()
|
||||||
|
if hasattr(self, 'search_input'):
|
||||||
|
self.search_input.clear()
|
||||||
|
if hasattr(self, 'search_widget'):
|
||||||
|
self.search_widget.hide()
|
||||||
|
|
||||||
def initUI(self):
|
def initUI(self):
|
||||||
self.setWindowTitle('SpotiFLAC')
|
self.setWindowTitle('SpotiFLAC')
|
||||||
self.setFixedWidth(650)
|
self.setFixedWidth(650)
|
||||||
self.setFixedHeight(350)
|
self.setMinimumHeight(350)
|
||||||
|
|
||||||
icon_path = os.path.join(os.path.dirname(__file__), "icon.svg")
|
icon_path = os.path.join(os.path.dirname(__file__), "icon.svg")
|
||||||
if os.path.exists(icon_path):
|
if os.path.exists(icon_path):
|
||||||
@@ -612,6 +696,27 @@ class SpotiFLACGUI(QWidget):
|
|||||||
spotify_layout.addWidget(self.fetch_btn)
|
spotify_layout.addWidget(self.fetch_btn)
|
||||||
self.main_layout.addLayout(spotify_layout)
|
self.main_layout.addLayout(spotify_layout)
|
||||||
|
|
||||||
|
def filter_tracks(self):
|
||||||
|
search_text = self.search_input.text().lower().strip()
|
||||||
|
|
||||||
|
if not search_text:
|
||||||
|
self.tracks = self.all_tracks.copy()
|
||||||
|
else:
|
||||||
|
self.tracks = [
|
||||||
|
track for track in self.all_tracks
|
||||||
|
if (search_text in track.title.lower() or
|
||||||
|
search_text in track.artists.lower() or
|
||||||
|
search_text in track.album.lower())
|
||||||
|
]
|
||||||
|
|
||||||
|
self.update_track_list_display()
|
||||||
|
|
||||||
|
def update_track_list_display(self):
|
||||||
|
self.track_list.clear()
|
||||||
|
for i, track in enumerate(self.tracks, 1):
|
||||||
|
duration = self.format_duration(track.duration_ms)
|
||||||
|
self.track_list.addItem(f"{i}. {track.title} - {track.artists} • {duration}")
|
||||||
|
|
||||||
def browse_output(self):
|
def browse_output(self):
|
||||||
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
|
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
|
||||||
if directory:
|
if directory:
|
||||||
@@ -680,10 +785,37 @@ class SpotiFLACGUI(QWidget):
|
|||||||
text_info_layout.addStretch()
|
text_info_layout.addStretch()
|
||||||
|
|
||||||
info_layout.addLayout(text_info_layout, 1)
|
info_layout.addLayout(text_info_layout, 1)
|
||||||
|
|
||||||
|
self.setup_search_widget()
|
||||||
|
info_layout.addWidget(self.search_widget)
|
||||||
|
|
||||||
self.info_widget.setLayout(info_layout)
|
self.info_widget.setLayout(info_layout)
|
||||||
self.info_widget.setFixedHeight(100)
|
self.info_widget.setFixedHeight(100)
|
||||||
self.info_widget.hide()
|
self.info_widget.hide()
|
||||||
|
|
||||||
|
def setup_search_widget(self):
|
||||||
|
self.search_widget = QWidget()
|
||||||
|
search_layout = QVBoxLayout()
|
||||||
|
search_layout.setContentsMargins(10, 0, 0, 0)
|
||||||
|
|
||||||
|
search_layout.addStretch()
|
||||||
|
|
||||||
|
search_input_layout = QHBoxLayout()
|
||||||
|
search_input_layout.addStretch()
|
||||||
|
|
||||||
|
self.search_input = QLineEdit()
|
||||||
|
self.search_input.setPlaceholderText("Search...")
|
||||||
|
self.search_input.setClearButtonEnabled(True)
|
||||||
|
self.search_input.textChanged.connect(self.filter_tracks)
|
||||||
|
self.search_input.setFixedWidth(250)
|
||||||
|
|
||||||
|
|
||||||
|
search_input_layout.addWidget(self.search_input)
|
||||||
|
search_layout.addLayout(search_input_layout)
|
||||||
|
|
||||||
|
self.search_widget.setLayout(search_layout)
|
||||||
|
self.search_widget.hide()
|
||||||
|
|
||||||
def setup_track_buttons(self):
|
def setup_track_buttons(self):
|
||||||
self.btn_layout = QHBoxLayout()
|
self.btn_layout = QHBoxLayout()
|
||||||
self.download_selected_btn = QPushButton('Download Selected')
|
self.download_selected_btn = QPushButton('Download Selected')
|
||||||
@@ -867,6 +999,8 @@ class SpotiFLACGUI(QWidget):
|
|||||||
region_label.hide()
|
region_label.hide()
|
||||||
self.qobuz_region_dropdown.hide()
|
self.qobuz_region_dropdown.hide()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
service_fallback_layout.addStretch()
|
service_fallback_layout.addStretch()
|
||||||
auth_layout.addLayout(service_fallback_layout)
|
auth_layout.addLayout(service_fallback_layout)
|
||||||
|
|
||||||
@@ -884,6 +1018,8 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.qobuz_region_dropdown.setCurrentIndex(i)
|
self.qobuz_region_dropdown.setCurrentIndex(i)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
self.update_service_ui()
|
self.update_service_ui()
|
||||||
|
|
||||||
self.qobuz_region_dropdown.status_updated.connect(
|
self.qobuz_region_dropdown.status_updated.connect(
|
||||||
@@ -939,7 +1075,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("v3.9 | July 2025")
|
footer_label = QLabel("v4.1 | July 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)
|
||||||
|
|
||||||
@@ -951,21 +1087,8 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.settings.setValue('service', service)
|
self.settings.setValue('service', service)
|
||||||
self.settings.sync()
|
self.settings.sync()
|
||||||
|
|
||||||
region_label = None
|
self.update_service_ui()
|
||||||
for widget in self.qobuz_region_dropdown.parentWidget().children():
|
self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}")
|
||||||
if isinstance(widget, QLabel) and widget.text() == "Region:":
|
|
||||||
region_label = widget
|
|
||||||
break
|
|
||||||
|
|
||||||
if service == "qobuz":
|
|
||||||
if region_label:
|
|
||||||
region_label.show()
|
|
||||||
self.qobuz_region_dropdown.show()
|
|
||||||
else:
|
|
||||||
if region_label:
|
|
||||||
region_label.hide()
|
|
||||||
self.qobuz_region_dropdown.hide()
|
|
||||||
self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}")
|
|
||||||
|
|
||||||
def update_service_ui(self):
|
def update_service_ui(self):
|
||||||
service = self.service
|
service = self.service
|
||||||
@@ -980,6 +1103,10 @@ class SpotiFLACGUI(QWidget):
|
|||||||
if region_label:
|
if region_label:
|
||||||
region_label.show()
|
region_label.show()
|
||||||
self.qobuz_region_dropdown.show()
|
self.qobuz_region_dropdown.show()
|
||||||
|
elif service == "deezer":
|
||||||
|
if region_label:
|
||||||
|
region_label.hide()
|
||||||
|
self.qobuz_region_dropdown.hide()
|
||||||
else:
|
else:
|
||||||
if region_label:
|
if region_label:
|
||||||
region_label.hide()
|
region_label.hide()
|
||||||
@@ -1015,6 +1142,8 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.settings.sync()
|
self.settings.sync()
|
||||||
self.log_output.append(f"Qobuz region setting saved: {self.qobuz_region_dropdown.currentText()}")
|
self.log_output.append(f"Qobuz region setting saved: {self.qobuz_region_dropdown.currentText()}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def save_settings(self):
|
def save_settings(self):
|
||||||
self.settings.setValue('output_path', self.output_dir.text().strip())
|
self.settings.setValue('output_path', self.output_dir.text().strip())
|
||||||
self.settings.sync()
|
self.settings.sync()
|
||||||
@@ -1068,7 +1197,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
def handle_track_metadata(self, track_data):
|
def handle_track_metadata(self, track_data):
|
||||||
track_id = track_data["external_urls"].split("/")[-1]
|
track_id = track_data["external_urls"].split("/")[-1]
|
||||||
|
|
||||||
self.tracks = [Track(
|
track = Track(
|
||||||
external_urls=track_data["external_urls"],
|
external_urls=track_data["external_urls"],
|
||||||
title=track_data["name"],
|
title=track_data["name"],
|
||||||
artists=track_data["artists"],
|
artists=track_data["artists"],
|
||||||
@@ -1077,7 +1206,10 @@ class SpotiFLACGUI(QWidget):
|
|||||||
duration_ms=track_data.get("duration_ms", 0),
|
duration_ms=track_data.get("duration_ms", 0),
|
||||||
id=track_id,
|
id=track_id,
|
||||||
isrc=track_data.get("isrc", "")
|
isrc=track_data.get("isrc", "")
|
||||||
)]
|
)
|
||||||
|
|
||||||
|
self.tracks = [track]
|
||||||
|
self.all_tracks = [track]
|
||||||
self.is_single_track = True
|
self.is_single_track = True
|
||||||
self.is_album = self.is_playlist = False
|
self.is_album = self.is_playlist = False
|
||||||
self.album_or_playlist_name = f"{self.tracks[0].title} - {self.tracks[0].artists}"
|
self.album_or_playlist_name = f"{self.tracks[0].title} - {self.tracks[0].artists}"
|
||||||
@@ -1109,6 +1241,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
isrc=track.get("isrc", "")
|
isrc=track.get("isrc", "")
|
||||||
))
|
))
|
||||||
|
|
||||||
|
self.all_tracks = self.tracks.copy()
|
||||||
self.is_album = True
|
self.is_album = True
|
||||||
self.is_playlist = self.is_single_track = False
|
self.is_playlist = self.is_single_track = False
|
||||||
|
|
||||||
@@ -1139,6 +1272,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
isrc=track.get("isrc", "")
|
isrc=track.get("isrc", "")
|
||||||
))
|
))
|
||||||
|
|
||||||
|
self.all_tracks = self.tracks.copy()
|
||||||
self.is_playlist = True
|
self.is_playlist = True
|
||||||
self.is_album = self.is_single_track = False
|
self.is_album = self.is_single_track = False
|
||||||
|
|
||||||
@@ -1155,10 +1289,10 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.track_list.setVisible(not self.is_single_track)
|
self.track_list.setVisible(not self.is_single_track)
|
||||||
|
|
||||||
if not self.is_single_track:
|
if not self.is_single_track:
|
||||||
self.track_list.clear()
|
self.search_widget.show()
|
||||||
for i, track in enumerate(self.tracks, 1):
|
self.update_track_list_display()
|
||||||
duration = self.format_duration(track.duration_ms)
|
else:
|
||||||
self.track_list.addItem(f"{i}. {track.title} - {track.artists} • {duration}")
|
self.search_widget.hide()
|
||||||
|
|
||||||
self.update_info_widget(metadata)
|
self.update_info_widget(metadata)
|
||||||
|
|
||||||
@@ -1264,13 +1398,14 @@ class SpotiFLACGUI(QWidget):
|
|||||||
if not selected_items:
|
if not selected_items:
|
||||||
self.log_output.append('Warning: Please select tracks to download.')
|
self.log_output.append('Warning: Please select tracks to download.')
|
||||||
return
|
return
|
||||||
self.download_tracks([self.track_list.row(item) for item in selected_items])
|
selected_indices = [self.track_list.row(item) for item in selected_items]
|
||||||
|
self.download_tracks(selected_indices)
|
||||||
|
|
||||||
def download_all(self):
|
def download_all(self):
|
||||||
if self.is_single_track:
|
if self.is_single_track:
|
||||||
self.download_tracks([0])
|
self.download_tracks([0])
|
||||||
else:
|
else:
|
||||||
self.download_tracks(range(self.track_list.count()))
|
self.download_tracks(range(len(self.tracks)))
|
||||||
|
|
||||||
def download_tracks(self, indices):
|
def download_tracks(self, indices):
|
||||||
self.log_output.clear()
|
self.log_output.clear()
|
||||||
@@ -1391,20 +1526,22 @@ class SpotiFLACGUI(QWidget):
|
|||||||
|
|
||||||
def remove_selected_tracks(self):
|
def remove_selected_tracks(self):
|
||||||
if not self.is_single_track:
|
if not self.is_single_track:
|
||||||
selected_indices = sorted([self.track_list.row(item) for item in self.track_list.selectedItems()], reverse=True)
|
selected_items = self.track_list.selectedItems()
|
||||||
|
selected_indices = [self.track_list.row(item) for item in selected_items]
|
||||||
|
|
||||||
for index in selected_indices:
|
tracks_to_remove = [self.tracks[i] for i in selected_indices]
|
||||||
self.track_list.takeItem(index)
|
|
||||||
self.tracks.pop(index)
|
|
||||||
|
|
||||||
for i, track in enumerate(self.tracks, 1):
|
for track in tracks_to_remove:
|
||||||
if self.is_playlist:
|
if track in self.tracks:
|
||||||
|
self.tracks.remove(track)
|
||||||
|
if track in self.all_tracks:
|
||||||
|
self.all_tracks.remove(track)
|
||||||
|
|
||||||
|
if self.is_playlist:
|
||||||
|
for i, track in enumerate(self.all_tracks, 1):
|
||||||
track.track_number = i
|
track.track_number = i
|
||||||
duration = self.format_duration(track.duration_ms)
|
|
||||||
display_text = f"{i}. {track.title} - {track.artists} • {duration}"
|
self.update_track_list_display()
|
||||||
list_item = self.track_list.item(i - 1)
|
|
||||||
if list_item:
|
|
||||||
list_item.setText(display_text)
|
|
||||||
|
|
||||||
def clear_tracks(self):
|
def clear_tracks(self):
|
||||||
self.reset_state()
|
self.reset_state()
|
||||||
|
|||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
import requests
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
|
||||||
|
class DeezerDownloader:
|
||||||
|
def __init__(self):
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
})
|
||||||
|
self.progress_callback = None
|
||||||
|
|
||||||
|
def set_progress_callback(self, callback):
|
||||||
|
self.progress_callback = callback
|
||||||
|
|
||||||
|
def get_track_by_isrc(self, isrc):
|
||||||
|
try:
|
||||||
|
url = f"https://api.deezer.com/2.0/track/isrc:{isrc}"
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if 'error' in data:
|
||||||
|
print(f"Error from Deezer API: {data['error']['message']}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Error fetching track data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_metadata(self, track_data):
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
metadata['title'] = track_data.get('title', '')
|
||||||
|
metadata['title_short'] = track_data.get('title_short', '')
|
||||||
|
metadata['duration'] = track_data.get('duration', 0)
|
||||||
|
metadata['track_position'] = track_data.get('track_position', 1)
|
||||||
|
metadata['disk_number'] = track_data.get('disk_number', 1)
|
||||||
|
metadata['isrc'] = track_data.get('isrc', '')
|
||||||
|
metadata['release_date'] = track_data.get('release_date', '')
|
||||||
|
metadata['explicit_lyrics'] = track_data.get('explicit_lyrics', False)
|
||||||
|
|
||||||
|
if 'artist' in track_data:
|
||||||
|
metadata['artist'] = track_data['artist'].get('name', '')
|
||||||
|
metadata['artist_id'] = track_data['artist'].get('id', '')
|
||||||
|
|
||||||
|
if 'contributors' in track_data:
|
||||||
|
artists = []
|
||||||
|
for contributor in track_data['contributors']:
|
||||||
|
if contributor.get('role') == 'Main':
|
||||||
|
artists.append(contributor.get('name', ''))
|
||||||
|
metadata['artists'] = ', '.join(artists) if artists else metadata.get('artist', '')
|
||||||
|
|
||||||
|
if 'album' in track_data:
|
||||||
|
album = track_data['album']
|
||||||
|
metadata['album'] = album.get('title', '')
|
||||||
|
metadata['album_id'] = album.get('id', '')
|
||||||
|
metadata['cover_url'] = album.get('cover_xl', album.get('cover_big', ''))
|
||||||
|
metadata['cover_md5'] = album.get('md5_image', '')
|
||||||
|
|
||||||
|
metadata['deezer_link'] = track_data.get('link', '')
|
||||||
|
metadata['preview_url'] = track_data.get('preview', '')
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def download_cover_art(self, cover_url, filename):
|
||||||
|
if not cover_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(cover_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
cover_path = f"{filename}_cover.jpg"
|
||||||
|
with open(cover_path, 'wb') as f:
|
||||||
|
f.write(response.content)
|
||||||
|
|
||||||
|
return cover_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading cover art: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def embed_metadata(self, file_path, metadata, cover_path=None):
|
||||||
|
try:
|
||||||
|
audio = FLAC(file_path)
|
||||||
|
|
||||||
|
audio.clear()
|
||||||
|
|
||||||
|
if metadata.get('title'):
|
||||||
|
audio['TITLE'] = metadata['title']
|
||||||
|
if metadata.get('artists'):
|
||||||
|
audio['ARTIST'] = metadata['artists']
|
||||||
|
elif metadata.get('artist'):
|
||||||
|
audio['ARTIST'] = metadata['artist']
|
||||||
|
if metadata.get('album'):
|
||||||
|
audio['ALBUM'] = metadata['album']
|
||||||
|
if metadata.get('release_date'):
|
||||||
|
audio['DATE'] = metadata['release_date']
|
||||||
|
if metadata.get('track_position'):
|
||||||
|
audio['TRACKNUMBER'] = str(metadata['track_position'])
|
||||||
|
if metadata.get('disk_number'):
|
||||||
|
audio['DISCNUMBER'] = str(metadata['disk_number'])
|
||||||
|
if metadata.get('isrc'):
|
||||||
|
audio['ISRC'] = metadata['isrc']
|
||||||
|
|
||||||
|
if cover_path and os.path.exists(cover_path):
|
||||||
|
with open(cover_path, 'rb') as f:
|
||||||
|
cover_data = f.read()
|
||||||
|
|
||||||
|
from mutagen.flac import Picture
|
||||||
|
picture = Picture()
|
||||||
|
picture.type = 3
|
||||||
|
picture.mime = 'image/jpeg'
|
||||||
|
picture.desc = 'Cover'
|
||||||
|
picture.data = cover_data
|
||||||
|
audio.add_picture(picture)
|
||||||
|
|
||||||
|
audio.save()
|
||||||
|
print(f"Metadata embedded successfully in {file_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error embedding metadata: {e}")
|
||||||
|
|
||||||
|
async def download_by_isrc(self, isrc, output_dir="."):
|
||||||
|
print(f"Fetching track info for ISRC: {isrc}")
|
||||||
|
|
||||||
|
track_data = self.get_track_by_isrc(isrc)
|
||||||
|
if not track_data:
|
||||||
|
print("Failed to get track data from Deezer API")
|
||||||
|
return False
|
||||||
|
|
||||||
|
metadata = self.extract_metadata(track_data)
|
||||||
|
print(f"Found track: {metadata.get('artists', 'Unknown')} - {metadata.get('title', 'Unknown')}")
|
||||||
|
|
||||||
|
track_id = track_data.get('id')
|
||||||
|
if not track_id:
|
||||||
|
print("No track ID found in Deezer API response")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Using track ID: {track_id}")
|
||||||
|
|
||||||
|
api_url = f"https://api.deezmate.com/dl/{track_id}"
|
||||||
|
print(f"Requesting download links from: {api_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(api_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
api_data = response.json()
|
||||||
|
|
||||||
|
if not api_data.get('success'):
|
||||||
|
print("API request failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
links = api_data.get('links', {})
|
||||||
|
flac_url = links.get('flac')
|
||||||
|
|
||||||
|
if not flac_url:
|
||||||
|
print("No FLAC download link found in API response")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Successfully obtained FLAC download URL")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting download URL from API: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("Downloading FLAC file...")
|
||||||
|
try:
|
||||||
|
response = self.session.get(flac_url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
|
print(f"File size: {total_size} bytes ({total_size / (1024*1024):.2f} MB)")
|
||||||
|
|
||||||
|
safe_title = "".join(c for c in metadata.get('title', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||||
|
safe_artist = "".join(c for c in metadata.get('artists', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||||
|
filename = f"{safe_artist} - {safe_title}.flac"
|
||||||
|
file_path = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if self.progress_callback and total_size > 0:
|
||||||
|
current_mb = downloaded / (1024 * 1024)
|
||||||
|
total_mb = total_size / (1024 * 1024)
|
||||||
|
percent = (downloaded / total_size) * 100
|
||||||
|
self.progress_callback(downloaded, total_size)
|
||||||
|
|
||||||
|
print(f"Downloaded: {file_path}")
|
||||||
|
|
||||||
|
cover_path = None
|
||||||
|
if metadata.get('cover_url'):
|
||||||
|
print("Downloading cover art...")
|
||||||
|
cover_path = self.download_cover_art(metadata['cover_url'],
|
||||||
|
os.path.join(output_dir, f"{safe_artist} - {safe_title}"))
|
||||||
|
|
||||||
|
print("Embedding metadata...")
|
||||||
|
self.embed_metadata(file_path, metadata, cover_path)
|
||||||
|
|
||||||
|
if cover_path and os.path.exists(cover_path):
|
||||||
|
os.remove(cover_path)
|
||||||
|
|
||||||
|
print(f"Successfully downloaded and tagged: {filename}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading file: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python deezerDL.py <ISRC>")
|
||||||
|
print("Example: python deezerDL.py USUM72409273")
|
||||||
|
return
|
||||||
|
|
||||||
|
isrc = sys.argv[1]
|
||||||
|
downloader = DeezerDownloader()
|
||||||
|
|
||||||
|
success = await downloader.download_by_isrc(isrc)
|
||||||
|
if success:
|
||||||
|
print("Download completed successfully!")
|
||||||
|
else:
|
||||||
|
print("Download failed!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
import nodriver as uc
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def download_deezer_track(deezer_link=None, initial_delay=7.5):
|
||||||
|
if deezer_link is None:
|
||||||
|
deezer_link = "https://www.deezer.com/us/track/2947516331"
|
||||||
|
|
||||||
|
browser = None
|
||||||
|
try:
|
||||||
|
browser = await uc.start(headless=False)
|
||||||
|
page = await browser.get("https://deezmate.com/en")
|
||||||
|
|
||||||
|
print("Loading...")
|
||||||
|
await asyncio.sleep(initial_delay)
|
||||||
|
|
||||||
|
input_selector = 'input[placeholder="Paste your Deezer link here..."]'
|
||||||
|
await page.wait_for(input_selector, timeout=15)
|
||||||
|
input_element = await page.select(input_selector)
|
||||||
|
await input_element.clear_input()
|
||||||
|
await input_element.send_keys(deezer_link)
|
||||||
|
print("Link entered")
|
||||||
|
|
||||||
|
await page.evaluate("""
|
||||||
|
window.apiResponse = null;
|
||||||
|
window.originalFetch = window.fetch;
|
||||||
|
window.fetch = function(...args) {
|
||||||
|
return window.originalFetch(...args).then(async response => {
|
||||||
|
if (response.url.includes('api.deezmate.com/dl/')) {
|
||||||
|
try {
|
||||||
|
const data = await response.clone().json();
|
||||||
|
window.apiResponse = data;
|
||||||
|
console.log('Captured API response:', data);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error parsing API response:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
""")
|
||||||
|
|
||||||
|
max_retries = 3
|
||||||
|
download_button_clicked = False
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
download_button_selector = 'button.bg-purple.hover\\:bg-purple-dark.cursor-pointer.transition.text-white.rounded-xl.p-2.mt-2.w-full.mb-5'
|
||||||
|
await page.wait_for(download_button_selector, timeout=15)
|
||||||
|
download_button = await page.select(download_button_selector)
|
||||||
|
await download_button.click()
|
||||||
|
print("Processing...")
|
||||||
|
download_button_clicked = True
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
print(f"Turnstile verification failed, retrying... ({attempt + 1}/{max_retries})")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await page.evaluate("window.apiResponse = null;")
|
||||||
|
else:
|
||||||
|
print("Failed to pass Turnstile verification after all retries")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
if not download_button_clicked:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
track_download_selector = 'button.bg-purple.text-white.flex.items-center.gap-2.px-3.py-1.rounded-full.hover\\:bg-purple-dark.transition'
|
||||||
|
await page.wait_for(track_download_selector, timeout=15)
|
||||||
|
track_download_button = await page.select(track_download_selector)
|
||||||
|
await track_download_button.click()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to click track download button: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print("Getting FLAC URL from API response...")
|
||||||
|
|
||||||
|
api_response = None
|
||||||
|
for i in range(30):
|
||||||
|
api_response = await page.evaluate("window.apiResponse")
|
||||||
|
if api_response:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
if not api_response:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_nodriver_response(data):
|
||||||
|
if isinstance(data, list):
|
||||||
|
result = {}
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, list) and len(item) == 2:
|
||||||
|
key = item[0]
|
||||||
|
value_obj = item[1]
|
||||||
|
if isinstance(value_obj, dict) and 'value' in value_obj:
|
||||||
|
if value_obj.get('type') == 'object':
|
||||||
|
result[key] = parse_nodriver_response(value_obj['value'])
|
||||||
|
else:
|
||||||
|
result[key] = value_obj['value']
|
||||||
|
return result
|
||||||
|
return data
|
||||||
|
|
||||||
|
parsed_response = parse_nodriver_response(api_response)
|
||||||
|
|
||||||
|
if parsed_response.get('success') and parsed_response.get('links'):
|
||||||
|
flac_url = parsed_response['links'].get('flac')
|
||||||
|
if flac_url:
|
||||||
|
print(f"Successfully obtained FLAC download URL: {flac_url}")
|
||||||
|
return flac_url
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if browser:
|
||||||
|
try:
|
||||||
|
await browser.stop()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def main(deezer_link=None, initial_delay=7.5):
|
||||||
|
flac_url = await download_deezer_track(deezer_link, initial_delay)
|
||||||
|
if not flac_url:
|
||||||
|
print("Failed to download track")
|
||||||
|
return flac_url
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uc.loop().run_until_complete(main())
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8"
|
"version": "4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user