Compare commits

..

7 Commits

Author SHA1 Message Date
afkarxyz 9bddeab0d1 v4.1 2025-07-24 06:14:42 +07:00
afkarxyz 03a30ee09a v4.0 2025-07-22 14:03:41 +07:00
afkarxyz 2d908e2f75 v4.0 2025-07-22 08:02:54 +07:00
afkarxyz e8f7bf7313 v4.0 2025-07-22 07:59:28 +07:00
afkarxyz 1f0922f358 v4.0 2025-07-22 07:54:03 +07:00
afkarxyz 3f267a3fa1 v3.9.5 2025-07-22 07:42:53 +07:00
afkarxyz 22da74a027 v3.9 2025-07-21 17:33:39 +07:00
5 changed files with 567 additions and 62 deletions
+8 -2
View File
@@ -3,10 +3,16 @@
![SpotiFLAC](https://github.com/user-attachments/assets/b4c4f403-edbd-4a71-b74b-c7d433d47d06) ![SpotiFLAC](https://github.com/user-attachments/assets/b4c4f403-edbd-4a71-b74b-c7d433d47d06)
<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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,3 @@
{ {
"version": "3.8" "version": "4.0"
} }