Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de16d9e25d | |||
| 6dd19b563b | |||
| 303b76d1ec | |||
| dbcd49225d |
@@ -6,7 +6,7 @@
|
|||||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal & Deezer.
|
<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/v4.1/SpotiFLAC.exe)
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.3/SpotiFLAC.exe)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
|||||||
+106
-53
@@ -23,6 +23,7 @@ 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
|
from deezerDL import DeezerDownloader
|
||||||
|
from amazonDL import LucidaDownloader
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Track:
|
class Track:
|
||||||
@@ -58,9 +59,10 @@ class MetadataFetchWorker(QThread):
|
|||||||
class DownloadWorker(QThread):
|
class DownloadWorker(QThread):
|
||||||
finished = pyqtSignal(bool, str, list)
|
finished = pyqtSignal(bool, str, list)
|
||||||
progress = pyqtSignal(str, int)
|
progress = pyqtSignal(str, int)
|
||||||
|
|
||||||
def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False,
|
def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False,
|
||||||
album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True,
|
album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True,
|
||||||
use_album_subfolders=False, service="tidal", qobuz_region="us"):
|
use_artist_subfolders=False, use_album_subfolders=False, service="tidal", qobuz_region="us"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.tracks = tracks
|
self.tracks = tracks
|
||||||
self.outpath = outpath
|
self.outpath = outpath
|
||||||
@@ -70,6 +72,7 @@ class DownloadWorker(QThread):
|
|||||||
self.album_or_playlist_name = album_or_playlist_name
|
self.album_or_playlist_name = album_or_playlist_name
|
||||||
self.filename_format = filename_format
|
self.filename_format = filename_format
|
||||||
self.use_track_numbers = use_track_numbers
|
self.use_track_numbers = use_track_numbers
|
||||||
|
self.use_artist_subfolders = use_artist_subfolders
|
||||||
self.use_album_subfolders = use_album_subfolders
|
self.use_album_subfolders = use_album_subfolders
|
||||||
self.service = service
|
self.service = service
|
||||||
self.qobuz_region = qobuz_region
|
self.qobuz_region = qobuz_region
|
||||||
@@ -91,16 +94,16 @@ class DownloadWorker(QThread):
|
|||||||
if self.service == "qobuz":
|
if self.service == "qobuz":
|
||||||
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":
|
elif self.service == "deezer":
|
||||||
downloader = DeezerDownloader()
|
downloader = DeezerDownloader()
|
||||||
|
elif self.service == "amazon":
|
||||||
|
downloader = LucidaDownloader()
|
||||||
else:
|
else:
|
||||||
downloader = TidalDownloader()
|
downloader = TidalDownloader()
|
||||||
|
|
||||||
def progress_update(current, total):
|
def progress_update(current, total):
|
||||||
if total > 0:
|
if total <= 0:
|
||||||
percent = (current / total) * 100
|
|
||||||
self.progress.emit("", int(percent))
|
|
||||||
else:
|
|
||||||
self.progress.emit("Processing metadata...", 0)
|
self.progress.emit("Processing metadata...", 0)
|
||||||
|
|
||||||
downloader.set_progress_callback(progress_update)
|
downloader.set_progress_callback(progress_update)
|
||||||
@@ -114,19 +117,28 @@ class DownloadWorker(QThread):
|
|||||||
self.msleep(100)
|
self.msleep(100)
|
||||||
if self.is_stopped:
|
if self.is_stopped:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.progress.emit(f"Starting download ({i+1}/{total_tracks}): {track.title} - {track.artists}",
|
self.progress.emit(f"Starting download ({i+1}/{total_tracks}): {track.title} - {track.artists}",
|
||||||
int((i) / total_tracks * 100))
|
int((i) / total_tracks * 100))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.is_playlist and self.use_album_subfolders:
|
if self.is_playlist:
|
||||||
album_folder = re.sub(r'[<>:"/\\|?*]', '_', track.album)
|
track_outpath = self.outpath
|
||||||
track_outpath = os.path.join(self.outpath, album_folder)
|
|
||||||
|
if self.use_artist_subfolders:
|
||||||
|
artist_name = track.artists.split(', ')[0] if ', ' in track.artists else track.artists
|
||||||
|
artist_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', artist_name)
|
||||||
|
track_outpath = os.path.join(track_outpath, artist_folder)
|
||||||
|
|
||||||
|
if self.use_album_subfolders:
|
||||||
|
album_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', track.album)
|
||||||
|
track_outpath = os.path.join(track_outpath, album_folder)
|
||||||
|
|
||||||
os.makedirs(track_outpath, exist_ok=True)
|
os.makedirs(track_outpath, exist_ok=True)
|
||||||
else:
|
else:
|
||||||
track_outpath = self.outpath
|
track_outpath = self.outpath
|
||||||
|
|
||||||
if (self.is_album or (self.is_playlist and self.use_album_subfolders)) and self.use_track_numbers:
|
if (self.is_album or self.is_playlist) and self.use_track_numbers:
|
||||||
new_filename = f"{track.track_number:02d} - {self.get_formatted_filename(track)}"
|
new_filename = f"{track.track_number:02d} - {self.get_formatted_filename(track)}"
|
||||||
else:
|
else:
|
||||||
new_filename = self.get_formatted_filename(track)
|
new_filename = self.get_formatted_filename(track)
|
||||||
@@ -214,6 +226,21 @@ class DownloadWorker(QThread):
|
|||||||
raise Exception("Downloaded file not found")
|
raise Exception("Downloaded file not found")
|
||||||
else:
|
else:
|
||||||
raise Exception("Deezer download failed")
|
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:
|
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)
|
||||||
@@ -359,6 +386,19 @@ class DeezerStatusChecker(QThread):
|
|||||||
self.error.emit(f"Error checking Deezer status: {str(e)}")
|
self.error.emit(f"Error checking Deezer status: {str(e)}")
|
||||||
self.status_updated.emit(False)
|
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):
|
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)
|
||||||
@@ -403,7 +443,16 @@ class ServiceComboBox(QComboBox):
|
|||||||
|
|
||||||
self.deezer_status_timer = QTimer(self)
|
self.deezer_status_timer = QTimer(self)
|
||||||
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
||||||
self.deezer_status_timer.start(60000)
|
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)
|
||||||
|
|
||||||
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__))
|
||||||
@@ -411,7 +460,8 @@ class ServiceComboBox(QComboBox):
|
|||||||
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}
|
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False},
|
||||||
|
{'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False}
|
||||||
]
|
]
|
||||||
|
|
||||||
for service in self.services:
|
for service in self.services:
|
||||||
@@ -467,6 +517,19 @@ class ServiceComboBox(QComboBox):
|
|||||||
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||||
self.deezer_status_checker.start()
|
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):
|
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
|
||||||
return super().currentData(role)
|
return super().currentData(role)
|
||||||
|
|
||||||
@@ -564,7 +627,7 @@ class QobuzRegionComboBox(QComboBox):
|
|||||||
class SpotiFLACGUI(QWidget):
|
class SpotiFLACGUI(QWidget):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.current_version = "4.2"
|
self.current_version = "4.4"
|
||||||
self.tracks = []
|
self.tracks = []
|
||||||
self.all_tracks = []
|
self.all_tracks = []
|
||||||
self.reset_state()
|
self.reset_state()
|
||||||
@@ -575,6 +638,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
|
|
||||||
self.filename_format = self.settings.value('filename_format', 'title_artist')
|
self.filename_format = self.settings.value('filename_format', 'title_artist')
|
||||||
self.use_track_numbers = self.settings.value('use_track_numbers', False, type=bool)
|
self.use_track_numbers = self.settings.value('use_track_numbers', False, type=bool)
|
||||||
|
self.use_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.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool)
|
||||||
self.service = self.settings.value('service', 'tidal')
|
self.service = self.settings.value('service', 'tidal')
|
||||||
self.qobuz_region = self.settings.value('qobuz_region', 'us')
|
self.qobuz_region = self.settings.value('qobuz_region', 'us')
|
||||||
@@ -798,7 +862,6 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.search_input.textChanged.connect(self.filter_tracks)
|
self.search_input.textChanged.connect(self.filter_tracks)
|
||||||
self.search_input.setFixedWidth(250)
|
self.search_input.setFixedWidth(250)
|
||||||
|
|
||||||
|
|
||||||
search_input_layout.addWidget(self.search_input)
|
search_input_layout.addWidget(self.search_input)
|
||||||
search_layout.addLayout(search_input_layout)
|
search_layout.addLayout(search_input_layout)
|
||||||
|
|
||||||
@@ -971,18 +1034,24 @@ class SpotiFLACGUI(QWidget):
|
|||||||
|
|
||||||
checkbox_layout = QHBoxLayout()
|
checkbox_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.track_number_checkbox = QCheckBox('Add Track Numbers to Album Files')
|
self.artist_subfolder_checkbox = QCheckBox('Artist Subfolder (Playlist)')
|
||||||
self.track_number_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.artist_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.track_number_checkbox.setChecked(self.use_track_numbers)
|
self.artist_subfolder_checkbox.setChecked(self.use_artist_subfolders)
|
||||||
self.track_number_checkbox.toggled.connect(self.save_track_numbering)
|
self.artist_subfolder_checkbox.toggled.connect(self.save_artist_subfolder_setting)
|
||||||
checkbox_layout.addWidget(self.track_number_checkbox)
|
checkbox_layout.addWidget(self.artist_subfolder_checkbox)
|
||||||
|
|
||||||
self.album_subfolder_checkbox = QCheckBox('Create Album Subfolders for Playlist Downloads')
|
self.album_subfolder_checkbox = QCheckBox('Album Subfolder (Playlist)')
|
||||||
self.album_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.album_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.album_subfolder_checkbox.setChecked(self.use_album_subfolders)
|
self.album_subfolder_checkbox.setChecked(self.use_album_subfolders)
|
||||||
self.album_subfolder_checkbox.toggled.connect(self.save_album_subfolder_setting)
|
self.album_subfolder_checkbox.toggled.connect(self.save_album_subfolder_setting)
|
||||||
checkbox_layout.addWidget(self.album_subfolder_checkbox)
|
checkbox_layout.addWidget(self.album_subfolder_checkbox)
|
||||||
|
|
||||||
|
self.track_number_checkbox = QCheckBox('Track Number for Album')
|
||||||
|
self.track_number_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.track_number_checkbox.setChecked(self.use_track_numbers)
|
||||||
|
self.track_number_checkbox.toggled.connect(self.save_track_numbering)
|
||||||
|
checkbox_layout.addWidget(self.track_number_checkbox)
|
||||||
|
|
||||||
checkbox_layout.addStretch()
|
checkbox_layout.addStretch()
|
||||||
file_layout.addLayout(checkbox_layout)
|
file_layout.addLayout(checkbox_layout)
|
||||||
|
|
||||||
@@ -1016,8 +1085,6 @@ 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)
|
||||||
|
|
||||||
@@ -1026,9 +1093,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
settings_tab.setLayout(settings_layout)
|
settings_tab.setLayout(settings_layout)
|
||||||
self.tab_widget.addTab(settings_tab, "Settings")
|
self.tab_widget.addTab(settings_tab, "Settings")
|
||||||
self.set_combobox_value(self.service_dropdown, self.service)
|
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_region_dropdown, self.qobuz_region)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
self.update_service_ui()
|
self.update_service_ui()
|
||||||
|
|
||||||
@@ -1237,7 +1302,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
|
|
||||||
about_layout.addWidget(section_widget)
|
about_layout.addWidget(section_widget)
|
||||||
|
|
||||||
footer_label = QLabel(f"v{self.current_version} | July 2025")
|
footer_label = QLabel(f"v{self.current_version} | August 2025")
|
||||||
footer_label.setStyleSheet("font-size: 12px; margin-top: 20px;")
|
footer_label.setStyleSheet("font-size: 12px; margin-top: 20px;")
|
||||||
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
@@ -1293,6 +1358,12 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.use_track_numbers = self.track_number_checkbox.isChecked()
|
self.use_track_numbers = self.track_number_checkbox.isChecked()
|
||||||
self.settings.setValue('use_track_numbers', self.use_track_numbers)
|
self.settings.setValue('use_track_numbers', self.use_track_numbers)
|
||||||
self.settings.sync()
|
self.settings.sync()
|
||||||
|
|
||||||
|
def save_artist_subfolder_setting(self):
|
||||||
|
self.use_artist_subfolders = self.artist_subfolder_checkbox.isChecked()
|
||||||
|
self.settings.setValue('use_artist_subfolders', self.use_artist_subfolders)
|
||||||
|
self.settings.sync()
|
||||||
|
|
||||||
def save_album_subfolder_setting(self):
|
def save_album_subfolder_setting(self):
|
||||||
self.use_album_subfolders = self.album_subfolder_checkbox.isChecked()
|
self.use_album_subfolders = self.album_subfolder_checkbox.isChecked()
|
||||||
self.settings.setValue('use_album_subfolders', self.use_album_subfolders)
|
self.settings.setValue('use_album_subfolders', self.use_album_subfolders)
|
||||||
@@ -1305,8 +1376,6 @@ 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()
|
||||||
@@ -1429,7 +1498,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
title=track["name"],
|
title=track["name"],
|
||||||
artists=track["artists"],
|
artists=track["artists"],
|
||||||
album=track["album_name"],
|
album=track["album_name"],
|
||||||
track_number=len(self.tracks) + 1,
|
track_number=track.get("track_number", len(self.tracks) + 1),
|
||||||
duration_ms=track.get("duration_ms", 0),
|
duration_ms=track.get("duration_ms", 0),
|
||||||
id=track_id,
|
id=track_id,
|
||||||
isrc=track.get("isrc", "")
|
isrc=track.get("isrc", "")
|
||||||
@@ -1568,7 +1637,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
if self.is_single_track:
|
if self.is_single_track:
|
||||||
self.download_all()
|
self.download_all()
|
||||||
else:
|
else:
|
||||||
selected_items = self.track_list.selectedItems()
|
selected_items = self.track_list.selectedItems()
|
||||||
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
|
||||||
@@ -1600,7 +1669,8 @@ class SpotiFLACGUI(QWidget):
|
|||||||
try:
|
try:
|
||||||
self.start_download_worker(tracks_to_download, outpath)
|
self.start_download_worker(tracks_to_download, outpath)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log_output.append(f"Error: An error occurred while starting the download: {str(e)}")
|
self.log_output.append(f"Error: An error occurred while starting the download: {str(e)}")
|
||||||
|
|
||||||
def start_download_worker(self, tracks_to_download, outpath):
|
def start_download_worker(self, tracks_to_download, outpath):
|
||||||
service = self.service_dropdown.currentData()
|
service = self.service_dropdown.currentData()
|
||||||
qobuz_region = self.qobuz_region_dropdown.currentData() if service == "qobuz" else "us"
|
qobuz_region = self.qobuz_region_dropdown.currentData() if service == "qobuz" else "us"
|
||||||
@@ -1614,6 +1684,7 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.album_or_playlist_name,
|
self.album_or_playlist_name,
|
||||||
self.filename_format,
|
self.filename_format,
|
||||||
self.use_track_numbers,
|
self.use_track_numbers,
|
||||||
|
self.use_artist_subfolders,
|
||||||
self.use_album_subfolders,
|
self.use_album_subfolders,
|
||||||
service,
|
service,
|
||||||
qobuz_region
|
qobuz_region
|
||||||
@@ -1641,27 +1712,9 @@ class SpotiFLACGUI(QWidget):
|
|||||||
self.tab_widget.setCurrentWidget(self.process_tab)
|
self.tab_widget.setCurrentWidget(self.process_tab)
|
||||||
|
|
||||||
def update_progress(self, message, percentage):
|
def update_progress(self, message, percentage):
|
||||||
if "Download progress:" in message or "Processing metadata..." in message:
|
self.log_output.append(message)
|
||||||
current_text = self.log_output.toPlainText()
|
self.log_output.moveCursor(QTextCursor.MoveOperation.End)
|
||||||
|
if percentage > 0:
|
||||||
if current_text:
|
|
||||||
lines = current_text.split('\n')
|
|
||||||
|
|
||||||
if "Download progress:" in lines[-1] or "Processing metadata..." in lines[-1]:
|
|
||||||
lines[-1] = message
|
|
||||||
|
|
||||||
new_text = '\n'.join(lines)
|
|
||||||
|
|
||||||
self.log_output.setPlainText(new_text)
|
|
||||||
|
|
||||||
self.log_output.moveCursor(QTextCursor.MoveOperation.End)
|
|
||||||
else:
|
|
||||||
self.log_output.append(message)
|
|
||||||
else:
|
|
||||||
self.log_output.append(message)
|
|
||||||
else:
|
|
||||||
self.log_output.append(message)
|
|
||||||
if percentage > 0 and not "Download progress:" in message:
|
|
||||||
self.progress_bar.setValue(percentage)
|
self.progress_bar.setValue(percentage)
|
||||||
|
|
||||||
def stop_download(self):
|
def stop_download(self):
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
+123
@@ -0,0 +1,123 @@
|
|||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
import urllib3
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
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': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
||||||
|
|
||||||
|
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"Starting download for: {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("Processing track...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = client.get(completion_url, headers=headers).json()
|
||||||
|
if resp["status"] == "completed":
|
||||||
|
print("Processing completed!")
|
||||||
|
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"Progress: {percent}%")
|
||||||
|
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: {file_name}")
|
||||||
|
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
print(f"Download completed: {file_path}")
|
||||||
|
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):
|
||||||
|
"""Download track using Lucida service"""
|
||||||
|
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__":
|
||||||
|
track_id = "2plbrEY59IikOBgBGLjaoe"
|
||||||
|
service = "amazon"
|
||||||
|
|
||||||
|
download_track(track_id, service)
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "4.1"
|
"version": "4.3"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user