import sys import os from dataclasses import dataclass from datetime import datetime from pathlib import Path import requests import re import asyncio from packaging import version import qdarktheme from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton, QAbstractItemView, QProgressBar, QCheckBox, QDialog, QDialogButtonBox, QComboBox, QStyledItemDelegate ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer, QTime, QSettings, QSize from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException from tidalDL import TidalDownloader from deezerDL import DeezerDownloader @dataclass class Track: external_urls: str title: str artists: str album: str track_number: int duration_ms: int id: str isrc: str = "" release_date: str = "" class MetadataFetchWorker(QThread): finished = pyqtSignal(dict) error = pyqtSignal(str) def __init__(self, url): super().__init__() self.url = url def run(self): try: metadata = get_filtered_data(self.url) if "error" in metadata: self.error.emit(metadata["error"]) else: self.finished.emit(metadata) except SpotifyInvalidUrlException as e: self.error.emit(str(e)) except Exception as e: self.error.emit(f'Failed to fetch metadata: {str(e)}') class DownloadWorker(QThread): finished = pyqtSignal(bool, str, list, list, list) progress = pyqtSignal(str, int) def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False, album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True, use_artist_subfolders=False, use_album_subfolders=False, service="tidal"): super().__init__() self.tracks = tracks self.outpath = outpath self.is_single_track = is_single_track self.is_album = is_album self.is_playlist = is_playlist self.album_or_playlist_name = album_or_playlist_name self.filename_format = filename_format self.use_track_numbers = use_track_numbers self.use_artist_subfolders = use_artist_subfolders self.use_album_subfolders = use_album_subfolders self.service = service self.is_paused = False self.is_stopped = False self.failed_tracks = [] self.successful_tracks = [] self.skipped_tracks = [] def get_formatted_filename(self, track): if self.filename_format == "artist_title": filename = f"{track.artists} - {track.title}.flac" elif self.filename_format == "title_only": filename = f"{track.title}.flac" else: filename = f"{track.title} - {track.artists}.flac" return re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', filename) def run(self): try: if self.service == "tidal": downloader = TidalDownloader() elif self.service == "deezer": downloader = DeezerDownloader() else: downloader = TidalDownloader() def progress_update(current, total): if total <= 0: self.progress.emit("Processing metadata...", 0) downloader.set_progress_callback(progress_update) total_tracks = len(self.tracks) for i, track in enumerate(self.tracks): while self.is_paused: if self.is_stopped: return self.msleep(100) if self.is_stopped: return self.progress.emit(f"Starting download ({i+1}/{total_tracks}): {track.title} - {track.artists}", int((i) / total_tracks * 100)) try: if self.is_playlist: track_outpath = self.outpath 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) else: track_outpath = self.outpath 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)}" else: new_filename = self.get_formatted_filename(track) new_filename = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', new_filename) new_filepath = os.path.join(track_outpath, new_filename) if os.path.exists(new_filepath) and os.path.getsize(new_filepath) > 0: self.progress.emit(f"File already exists: {new_filename}. Skipping download.", 0) self.progress.emit(f"Skipped: {track.title} - {track.artists}", int((i + 1) / total_tracks * 100)) self.skipped_tracks.append(track) continue if self.service == "tidal": if not track.isrc: self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0) self.failed_tracks.append((track.title, track.artists, "No ISRC available")) continue self.progress.emit(f"Searching and downloading from Tidal for ISRC: {track.isrc} - {track.title} - {track.artists}", 0) is_paused_callback = lambda: self.is_paused is_stopped_callback = lambda: self.is_stopped download_result_details = downloader.download( query=f"{track.title} {track.artists}", isrc=track.isrc, output_dir=track_outpath, quality="LOSSLESS", is_paused_callback=is_paused_callback, is_stopped_callback=is_stopped_callback ) if isinstance(download_result_details, str) and os.path.exists(download_result_details): downloaded_file = download_result_details elif isinstance(download_result_details, dict) and download_result_details.get("success") == False and download_result_details.get("error") == "Download stopped by user": self.progress.emit(f"Download stopped by user for: {track.title}",0) return elif isinstance(download_result_details, dict) and download_result_details.get("success") == False: raise Exception(download_result_details.get("error", "Tidal download failed")) elif isinstance(download_result_details, dict) and (download_result_details.get("status") == "all_skipped" or download_result_details.get("status") == "skipped_exists"): self.progress.emit(f"File already exists or skipped: {new_filename}",0) downloaded_file = new_filepath self.skipped_tracks.append(track) else: downloaded_file = None 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) success = asyncio.run(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: track_id = track.id self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 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) metadata = loop.run_until_complete(downloader.get_track_info(track_id, self.service)) self.progress.emit(f"Track info received, starting download process", 0) is_paused_callback = lambda: self.is_paused is_stopped_callback = lambda: self.is_stopped downloaded_file = downloader.download( metadata, track_outpath, is_paused_callback=is_paused_callback, is_stopped_callback=is_stopped_callback ) if self.is_stopped: return if downloaded_file and os.path.exists(downloaded_file): if downloaded_file == new_filepath: self.progress.emit(f"File already exists: {new_filename}", 0) self.progress.emit(f"Skipped: {track.title} - {track.artists}", int((i + 1) / total_tracks * 100)) self.skipped_tracks.append(track) continue if downloaded_file != new_filepath: try: os.rename(downloaded_file, new_filepath) 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}", int((i + 1) / total_tracks * 100)) self.successful_tracks.append(track) except Exception as e: self.failed_tracks.append((track.title, track.artists, str(e))) self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nError: {str(e)}", int((i + 1) / total_tracks * 100)) continue if not self.is_stopped: success_message = "Download completed!" if self.failed_tracks: success_message += f"\n\nFailed downloads: {len(self.failed_tracks)} tracks" if self.successful_tracks: success_message += f"\n\nSuccessful downloads: {len(self.successful_tracks)} tracks" if self.skipped_tracks: success_message += f"\n\nSkipped (already exists): {len(self.skipped_tracks)} tracks" self.finished.emit(True, success_message, self.failed_tracks, self.successful_tracks, self.skipped_tracks) except Exception as e: self.finished.emit(False, str(e), self.failed_tracks, self.successful_tracks, self.skipped_tracks) def pause(self): self.is_paused = True self.progress.emit("Download process paused.", 0) def resume(self): self.is_paused = False self.progress.emit("Download process resumed.", 0) def stop(self): self.is_stopped = True self.is_paused = False class UpdateDialog(QDialog): def __init__(self, current_version, new_version, parent=None): super().__init__(parent) self.setWindowTitle("Update Now") self.setFixedWidth(400) self.setModal(True) layout = QVBoxLayout() message = QLabel(f"SpotiFLAC v{new_version} Available!") message.setWordWrap(True) layout.addWidget(message) button_box = QDialogButtonBox() self.update_button = QPushButton("Check") self.update_button.setCursor(Qt.CursorShape.PointingHandCursor) self.cancel_button = QPushButton("Later") self.cancel_button.setCursor(Qt.CursorShape.PointingHandCursor) button_box.addButton(self.update_button, QDialogButtonBox.ButtonRole.AcceptRole) button_box.addButton(self.cancel_button, QDialogButtonBox.ButtonRole.RejectRole) layout.addWidget(button_box) self.setLayout(layout) self.update_button.clicked.connect(self.accept) self.cancel_button.clicked.connect(self.reject) class TidalStatusChecker(QThread): status_updated = pyqtSignal(bool) error = pyqtSignal(str) def run(self): try: response = requests.get("https://tidal.401658.xyz", timeout=5) is_online = response.status_code == 200 or response.status_code == 429 self.status_updated.emit(is_online) except Exception as e: self.error.emit(f"Error checking Tidal (API) status: {str(e)}") self.status_updated.emit(False) class 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): def paint(self, painter, option, index): item_data = index.data(Qt.ItemDataRole.UserRole) is_online = item_data.get('online', False) if item_data else False super().paint(painter, option, index) indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red circle_size = 6 circle_y = option.rect.center().y() - circle_size // 2 circle_x = option.rect.right() - circle_size - 5 painter.save() painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(indicator_color)) painter.drawEllipse(circle_x, circle_y, circle_size, circle_size) painter.restore() class ServiceComboBox(QComboBox): def __init__(self, parent=None): super().__init__(parent) self.setIconSize(QSize(16, 16)) self.services_status = {} self.setItemDelegate(StatusIndicatorDelegate()) self.setup_items() self.tidal_status_checker = TidalStatusChecker() self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status) self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}")) self.tidal_status_checker.start() self.tidal_status_timer = QTimer(self) self.tidal_status_timer.timeout.connect(self.refresh_tidal_status) self.tidal_status_timer.start(60000) 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(60000) def setup_items(self): current_dir = os.path.dirname(os.path.abspath(__file__)) self.services = [ {'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False}, {'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False} ] for service in self.services: icon_path = os.path.join(current_dir, service['icon']) if not os.path.exists(icon_path): self.create_placeholder_icon(icon_path) icon = QIcon(icon_path) self.addItem(icon, service['name']) item_index = self.count() - 1 self.setItemData(item_index, service['id'], Qt.ItemDataRole.UserRole + 1) self.setItemData(item_index, service, Qt.ItemDataRole.UserRole) def create_placeholder_icon(self, path): pixmap = QPixmap(16, 16) pixmap.fill(Qt.GlobalColor.transparent) pixmap.save(path) def update_service_status(self, service_id, is_online): for i in range(self.count()): current_service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1) if current_service_id == service_id: 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 update_tidal_service_status(self, is_online): self.update_service_status('tidal', is_online) def refresh_tidal_status(self): if hasattr(self, 'tidal_status_checker') and self.tidal_status_checker.isRunning(): self.tidal_status_checker.quit() self.tidal_status_checker.wait() self.tidal_status_checker = TidalStatusChecker() self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status) self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}")) self.tidal_status_checker.start() def update_deezer_service_status(self, is_online): self.update_service_status('deezer', is_online) def refresh_deezer_status(self): if hasattr(self, 'deezer_status_checker') and self.deezer_status_checker.isRunning(): self.deezer_status_checker.quit() self.deezer_status_checker.wait() 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): return super().currentData(role) class SpotiFLACGUI(QWidget): def __init__(self): super().__init__() self.current_version = "4.8" self.tracks = [] self.all_tracks = [] self.successful_downloads = [] self.reset_state() self.settings = QSettings('SpotiFLAC', 'Settings') self.last_output_path = self.settings.value('output_path', str(Path.home() / "Music")) self.last_url = self.settings.value('spotify_url', '') 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_artist_subfolders = self.settings.value('use_artist_subfolders', False, type=bool) self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool) self.service = self.settings.value('service', 'tidal') self.check_for_updates = self.settings.value('check_for_updates', True, type=bool) self.current_theme_color = self.settings.value('theme_color', '#2196F3') self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration') self.date_format = self.settings.value('date_format', 'dd_mm_yyyy') self.elapsed_time = QTime(0, 0, 0) self.timer = QTimer(self) self.timer.timeout.connect(self.update_timer) self.network_manager = QNetworkAccessManager() self.network_manager.finished.connect(self.on_cover_loaded) self.initUI() if self.check_for_updates: QTimer.singleShot(0, self.check_updates) def set_combobox_value(self, combobox, target_value): for i in range(combobox.count()): if combobox.itemData(i, Qt.ItemDataRole.UserRole + 1) == target_value: combobox.setCurrentIndex(i) return True if combobox.itemData(i, Qt.ItemDataRole.UserRole) == target_value: combobox.setCurrentIndex(i) return True return False def check_updates(self): try: response = requests.get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json") if response.status_code == 200: data = response.json() new_version = data.get("version") if new_version and version.parse(new_version) > version.parse(self.current_version): dialog = UpdateDialog(self.current_version, new_version, self) result = dialog.exec() if result == QDialog.DialogCode.Accepted: QDesktopServices.openUrl(QUrl("https://github.com/afkarxyz/SpotiFLAC/releases")) except Exception as e: pass @staticmethod def format_duration(ms): minutes = ms // 60000 seconds = (ms % 60000) // 1000 return f"{minutes}:{seconds:02d}" def reset_state(self): self.tracks.clear() self.all_tracks.clear() self.is_album = False self.is_playlist = False self.is_single_track = False self.album_or_playlist_name = '' def reset_ui(self): self.track_list.clear() self.log_output.clear() self.progress_bar.setValue(0) self.progress_bar.hide() self.stop_btn.hide() self.pause_resume_btn.hide() self.pause_resume_btn.setText('Pause') self.reset_info_widget() 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): self.setWindowTitle('SpotiFLAC') self.setFixedWidth(650) self.setMinimumHeight(350) icon_path = os.path.join(os.path.dirname(__file__), "icon.svg") if os.path.exists(icon_path): self.setWindowIcon(QIcon(icon_path)) self.main_layout = QVBoxLayout() self.setup_spotify_section() self.setup_tabs() self.setLayout(self.main_layout) def setup_spotify_section(self): spotify_layout = QHBoxLayout() spotify_label = QLabel('Spotify URL:') spotify_label.setFixedWidth(100) self.spotify_url = QLineEdit() self.spotify_url.setPlaceholderText("Enter Spotify URL") self.spotify_url.setClearButtonEnabled(True) self.spotify_url.setText(self.last_url) self.spotify_url.textChanged.connect(self.save_url) self.fetch_btn = QPushButton('Fetch') self.fetch_btn.setFixedWidth(80) self.fetch_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.fetch_btn.clicked.connect(self.fetch_tracks) spotify_layout.addWidget(spotify_label) spotify_layout.addWidget(self.spotify_url) spotify_layout.addWidget(self.fetch_btn) 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 format_track_date(self, release_date): if not release_date: return "" try: if len(release_date) == 4: date_obj = datetime.strptime(release_date, "%Y") if self.date_format == "yyyy": return date_obj.strftime('%Y') else: return date_obj.strftime('%Y') elif len(release_date) == 7: date_obj = datetime.strptime(release_date, "%Y-%m") if self.date_format == "dd_mm_yyyy": return date_obj.strftime('%m-%Y') elif self.date_format == "yyyy_mm_dd": return date_obj.strftime('%Y-%m') else: return date_obj.strftime('%Y') else: date_obj = datetime.strptime(release_date, "%Y-%m-%d") if self.date_format == "dd_mm_yyyy": return date_obj.strftime('%d-%m-%Y') elif self.date_format == "yyyy_mm_dd": return date_obj.strftime('%Y-%m-%d') else: return date_obj.strftime('%Y') except ValueError: return release_date 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) formatted_date = self.format_track_date(track.release_date) if self.track_list_format == "artist_track_date_duration": display_parts = [f"{i}. {track.artists} - {track.title}"] if formatted_date: display_parts.append(formatted_date) display_parts.append(duration) display_text = " • ".join(display_parts) elif self.track_list_format == "track_artist_date": display_parts = [f"{i}. {track.title} - {track.artists}"] if formatted_date: display_parts.append(formatted_date) display_text = " • ".join(display_parts) elif self.track_list_format == "artist_track_date": display_parts = [f"{i}. {track.artists} - {track.title}"] if formatted_date: display_parts.append(formatted_date) display_text = " • ".join(display_parts) elif self.track_list_format == "track_artist_duration": display_text = f"{i}. {track.title} - {track.artists} • {duration}" elif self.track_list_format == "artist_track_duration": display_text = f"{i}. {track.artists} - {track.title} • {duration}" elif self.track_list_format == "track_artist": display_text = f"{i}. {track.title} - {track.artists}" elif self.track_list_format == "artist_track": display_text = f"{i}. {track.artists} - {track.title}" else: display_parts = [f"{i}. {track.title} - {track.artists}"] if formatted_date: display_parts.append(formatted_date) display_parts.append(duration) display_text = " • ".join(display_parts) self.track_list.addItem(display_text) def browse_output(self): directory = QFileDialog.getExistingDirectory(self, "Select Output Directory") if directory: self.output_dir.setText(directory) self.save_settings() def setup_tabs(self): self.tab_widget = QTabWidget() self.main_layout.addWidget(self.tab_widget) self.setup_dashboard_tab() self.setup_process_tab() self.setup_settings_tab() self.setup_theme_tab() self.setup_about_tab() def setup_dashboard_tab(self): dashboard_tab = QWidget() dashboard_layout = QVBoxLayout() self.setup_info_widget() dashboard_layout.addWidget(self.info_widget) self.track_list = QListWidget() self.track_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) dashboard_layout.addWidget(self.track_list) self.setup_track_buttons() dashboard_layout.addLayout(self.btn_layout) dashboard_layout.addWidget(self.single_track_container) dashboard_tab.setLayout(dashboard_layout) self.tab_widget.addTab(dashboard_tab, "Dashboard") self.hide_track_buttons() def setup_info_widget(self): self.info_widget = QWidget() info_layout = QHBoxLayout() self.cover_label = QLabel() self.cover_label.setFixedSize(80, 80) self.cover_label.setScaledContents(True) info_layout.addWidget(self.cover_label) text_info_layout = QVBoxLayout() self.title_label = QLabel() self.title_label.setStyleSheet("font-size: 14px; font-weight: bold;") self.title_label.setWordWrap(True) self.artists_label = QLabel() self.artists_label.setWordWrap(True) self.followers_label = QLabel() self.followers_label.setWordWrap(True) self.release_date_label = QLabel() self.release_date_label.setWordWrap(True) self.type_label = QLabel() self.type_label.setStyleSheet("font-size: 12px;") text_info_layout.addWidget(self.title_label) text_info_layout.addWidget(self.artists_label) text_info_layout.addWidget(self.followers_label) text_info_layout.addWidget(self.release_date_label) text_info_layout.addWidget(self.type_label) text_info_layout.addStretch() 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.setFixedHeight(100) 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): self.btn_layout = QHBoxLayout() self.download_selected_btn = QPushButton('Download Selected') self.download_all_btn = QPushButton('Download All') self.remove_btn = QPushButton('Remove Selected') self.clear_btn = QPushButton('Clear') for btn in [self.download_selected_btn, self.download_all_btn, self.remove_btn, self.clear_btn]: btn.setMinimumWidth(120) btn.setCursor(Qt.CursorShape.PointingHandCursor) self.download_selected_btn.clicked.connect(self.download_selected) self.download_all_btn.clicked.connect(self.download_all) self.remove_btn.clicked.connect(self.remove_selected_tracks) self.clear_btn.clicked.connect(self.clear_tracks) self.btn_layout.addStretch() for btn in [self.download_selected_btn, self.download_all_btn, self.remove_btn, self.clear_btn]: self.btn_layout.addWidget(btn, 1) self.btn_layout.addStretch() self.single_track_container = QWidget() single_track_layout = QHBoxLayout(self.single_track_container) single_track_layout.setContentsMargins(0, 0, 0, 0) self.single_download_btn = QPushButton('Download') self.single_clear_btn = QPushButton('Clear') for btn in [self.single_download_btn, self.single_clear_btn]: btn.setFixedWidth(120) btn.setCursor(Qt.CursorShape.PointingHandCursor) self.single_download_btn.clicked.connect(self.download_all) self.single_clear_btn.clicked.connect(self.clear_tracks) single_track_layout.addStretch() single_track_layout.addWidget(self.single_download_btn) single_track_layout.addWidget(self.single_clear_btn) single_track_layout.addStretch() self.single_track_container.hide() def setup_process_tab(self): self.process_tab = QWidget() process_layout = QVBoxLayout() process_layout.setSpacing(5) self.log_output = QTextEdit() self.log_output.setReadOnly(True) process_layout.addWidget(self.log_output) progress_time_layout = QVBoxLayout() progress_time_layout.setSpacing(2) self.progress_bar = QProgressBar() progress_time_layout.addWidget(self.progress_bar) self.time_label = QLabel("00:00:00") self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) progress_time_layout.addWidget(self.time_label) process_layout.addLayout(progress_time_layout) control_layout = QHBoxLayout() self.stop_btn = QPushButton('Stop') self.pause_resume_btn = QPushButton('Pause') self.stop_btn.setFixedWidth(120) self.pause_resume_btn.setFixedWidth(120) self.stop_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.pause_resume_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.stop_btn.clicked.connect(self.stop_download) self.pause_resume_btn.clicked.connect(self.toggle_pause_resume) self.remove_successful_btn = QPushButton('Remove Finished Songs') self.remove_successful_btn.setFixedWidth(200) self.remove_successful_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.remove_successful_btn.clicked.connect(self.remove_successful_downloads) control_layout.addStretch() control_layout.addWidget(self.stop_btn) control_layout.addWidget(self.pause_resume_btn) control_layout.addWidget(self.remove_successful_btn) control_layout.addStretch() process_layout.addLayout(control_layout) self.process_tab.setLayout(process_layout) self.tab_widget.addTab(self.process_tab, "Process") self.progress_bar.hide() self.time_label.hide() self.stop_btn.hide() self.pause_resume_btn.hide() self.remove_successful_btn.hide() def setup_settings_tab(self): settings_tab = QWidget() settings_layout = QVBoxLayout() settings_layout.setSpacing(4) settings_layout.setContentsMargins(10, 10, 10, 10) output_group = QWidget() output_layout = QVBoxLayout(output_group) output_layout.setSpacing(2) output_layout.setContentsMargins(0, 0, 0, 0) output_label = QLabel('Output Directory') output_label.setStyleSheet("font-weight: bold; margin-top: 0px; margin-bottom: 5px;") output_layout.addWidget(output_label) output_dir_layout = QHBoxLayout() self.output_dir = QLineEdit() self.output_dir.setText(self.last_output_path) self.output_dir.textChanged.connect(self.save_settings) self.output_browse = QPushButton('Browse') self.output_browse.setFixedWidth(80) self.output_browse.setCursor(Qt.CursorShape.PointingHandCursor) self.output_browse.clicked.connect(self.browse_output) output_dir_layout.addWidget(self.output_dir) output_dir_layout.addSpacing(5) output_dir_layout.addWidget(self.output_browse) output_layout.addLayout(output_dir_layout) settings_layout.addWidget(output_group) dashboard_group = QWidget() dashboard_layout = QVBoxLayout(dashboard_group) dashboard_layout.setSpacing(3) dashboard_layout.setContentsMargins(0, 0, 0, 0) dashboard_label = QLabel('Dashboard Settings') dashboard_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;") dashboard_layout.addWidget(dashboard_label) dashboard_controls_layout = QHBoxLayout() list_format_label = QLabel('Track List View:') list_format_label.setFixedWidth(90) self.track_list_format_dropdown = QComboBox() self.track_list_format_dropdown.addItem("Track - Artist - Date - Duration", "track_artist_date_duration") self.track_list_format_dropdown.addItem("Artist - Track - Date - Duration", "artist_track_date_duration") self.track_list_format_dropdown.addItem("Track - Artist - Date", "track_artist_date") self.track_list_format_dropdown.addItem("Artist - Track - Date", "artist_track_date") self.track_list_format_dropdown.addItem("Track - Artist - Duration", "track_artist_duration") self.track_list_format_dropdown.addItem("Artist - Track - Duration", "artist_track_duration") self.track_list_format_dropdown.addItem("Track - Artist", "track_artist") self.track_list_format_dropdown.addItem("Artist - Track", "artist_track") self.track_list_format_dropdown.currentIndexChanged.connect(self.save_track_list_format) dashboard_controls_layout.addWidget(list_format_label) dashboard_controls_layout.addWidget(self.track_list_format_dropdown) dashboard_controls_layout.addSpacing(15) date_format_label = QLabel('Date Format:') date_format_label.setFixedWidth(80) self.date_format_dropdown = QComboBox() self.date_format_dropdown.addItem("DD-MM-YYYY", "dd_mm_yyyy") self.date_format_dropdown.addItem("YYYY-MM-DD", "yyyy_mm_dd") self.date_format_dropdown.addItem("YYYY", "yyyy") self.date_format_dropdown.currentIndexChanged.connect(self.save_date_format) dashboard_controls_layout.addWidget(date_format_label) dashboard_controls_layout.addWidget(self.date_format_dropdown) dashboard_controls_layout.addStretch() dashboard_layout.addLayout(dashboard_controls_layout) settings_layout.addWidget(dashboard_group) file_group = QWidget() file_layout = QVBoxLayout(file_group) file_layout.setSpacing(2) file_layout.setContentsMargins(0, 0, 0, 0) file_label = QLabel('File Settings') file_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;") file_layout.addWidget(file_label) format_layout = QHBoxLayout() format_label = QLabel('Filename Format:') self.format_group = QButtonGroup(self) self.title_artist_radio = QRadioButton('Title - Artist') self.title_artist_radio.setCursor(Qt.CursorShape.PointingHandCursor) self.title_artist_radio.toggled.connect(self.save_filename_format) self.artist_title_radio = QRadioButton('Artist - Title') self.artist_title_radio.setCursor(Qt.CursorShape.PointingHandCursor) self.artist_title_radio.toggled.connect(self.save_filename_format) self.title_only_radio = QRadioButton('Title') self.title_only_radio.setCursor(Qt.CursorShape.PointingHandCursor) self.title_only_radio.toggled.connect(self.save_filename_format) if hasattr(self, 'filename_format') and self.filename_format == "artist_title": self.artist_title_radio.setChecked(True) elif hasattr(self, 'filename_format') and self.filename_format == "title_only": self.title_only_radio.setChecked(True) else: self.title_artist_radio.setChecked(True) self.format_group.addButton(self.title_artist_radio) self.format_group.addButton(self.artist_title_radio) self.format_group.addButton(self.title_only_radio) format_layout.addWidget(format_label) format_layout.addWidget(self.title_artist_radio) format_layout.addSpacing(10) format_layout.addWidget(self.artist_title_radio) format_layout.addSpacing(10) format_layout.addWidget(self.title_only_radio) format_layout.addStretch() file_layout.addLayout(format_layout) checkbox_layout = QHBoxLayout() self.artist_subfolder_checkbox = QCheckBox('Artist Subfolder (Playlist)') self.artist_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor) self.artist_subfolder_checkbox.setChecked(self.use_artist_subfolders) self.artist_subfolder_checkbox.toggled.connect(self.save_artist_subfolder_setting) checkbox_layout.addWidget(self.artist_subfolder_checkbox) checkbox_layout.addSpacing(10) self.album_subfolder_checkbox = QCheckBox('Album Subfolder (Playlist)') self.album_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor) self.album_subfolder_checkbox.setChecked(self.use_album_subfolders) self.album_subfolder_checkbox.toggled.connect(self.save_album_subfolder_setting) checkbox_layout.addWidget(self.album_subfolder_checkbox) checkbox_layout.addSpacing(10) self.track_number_checkbox = QCheckBox('Track Number') 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() file_layout.addLayout(checkbox_layout) settings_layout.addWidget(file_group) auth_group = QWidget() auth_layout = QVBoxLayout(auth_group) auth_layout.setSpacing(2) auth_layout.setContentsMargins(0, 0, 0, 0) auth_label = QLabel('Service Settings') auth_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;") auth_layout.addWidget(auth_label) service_fallback_layout = QHBoxLayout() service_label = QLabel('Service:') self.service_dropdown = ServiceComboBox() self.service_dropdown.currentIndexChanged.connect(self.on_service_changed) service_fallback_layout.addWidget(service_label) service_fallback_layout.addWidget(self.service_dropdown) service_fallback_layout.addStretch() auth_layout.addLayout(service_fallback_layout) settings_layout.addWidget(auth_group) settings_layout.addStretch() settings_tab.setLayout(settings_layout) self.tab_widget.addTab(settings_tab, "Settings") self.set_combobox_value(self.service_dropdown, self.service) self.set_combobox_value(self.track_list_format_dropdown, self.track_list_format) self.set_combobox_value(self.date_format_dropdown, self.date_format) def setup_theme_tab(self): theme_tab = QWidget() theme_layout = QVBoxLayout() theme_layout.setSpacing(8) theme_layout.setContentsMargins(8, 15, 15, 15) grid_layout = QVBoxLayout() self.color_buttons = {} first_row_palettes = [ ("Red", [ ("#FFCDD2", "100"), ("#EF9A9A", "200"), ("#E57373", "300"), ("#EF5350", "400"), ("#F44336", "500"), ("#E53935", "600"), ("#D32F2F", "700"), ("#C62828", "800"), ("#B71C1C", "900"), ("#FF8A80", "A100"), ("#FF5252", "A200"), ("#FF1744", "A400"), ("#D50000", "A700") ]), ("Pink", [ ("#F8BBD0", "100"), ("#F48FB1", "200"), ("#F06292", "300"), ("#EC407A", "400"), ("#E91E63", "500"), ("#D81B60", "600"), ("#C2185B", "700"), ("#AD1457", "800"), ("#880E4F", "900"), ("#FF80AB", "A100"), ("#FF4081", "A200"), ("#F50057", "A400"), ("#C51162", "A700") ]), ("Purple", [ ("#E1BEE7", "100"), ("#CE93D8", "200"), ("#BA68C8", "300"), ("#AB47BC", "400"), ("#9C27B0", "500"), ("#8E24AA", "600"), ("#7B1FA2", "700"), ("#6A1B9A", "800"), ("#4A148C", "900"), ("#EA80FC", "A100"), ("#E040FB", "A200"), ("#D500F9", "A400"), ("#AA00FF", "A700") ]) ] second_row_palettes = [ ("Deep Purple", [ ("#D1C4E9", "100"), ("#B39DDB", "200"), ("#9575CD", "300"), ("#7E57C2", "400"), ("#673AB7", "500"), ("#5E35B1", "600"), ("#512DA8", "700"), ("#4527A0", "800"), ("#311B92", "900"), ("#B388FF", "A100"), ("#7C4DFF", "A200"), ("#651FFF", "A400"), ("#6200EA", "A700") ]), ("Indigo", [ ("#C5CAE9", "100"), ("#9FA8DA", "200"), ("#7986CB", "300"), ("#5C6BC0", "400"), ("#3F51B5", "500"), ("#3949AB", "600"), ("#303F9F", "700"), ("#283593", "800"), ("#1A237E", "900"), ("#8C9EFF", "A100"), ("#536DFE", "A200"), ("#3D5AFE", "A400"), ("#304FFE", "A700") ]), ("Blue", [ ("#BBDEFB", "100"), ("#90CAF9", "200"), ("#64B5F6", "300"), ("#42A5F5", "400"), ("#2196F3", "500"), ("#1E88E5", "600"), ("#1976D2", "700"), ("#1565C0", "800"), ("#0D47A1", "900"), ("#82B1FF", "A100"), ("#448AFF", "A200"), ("#2979FF", "A400"), ("#2962FF", "A700") ]) ] third_row_palettes = [ ("Light Blue", [ ("#B3E5FC", "100"), ("#81D4FA", "200"), ("#4FC3F7", "300"), ("#29B6F6", "400"), ("#03A9F4", "500"), ("#039BE5", "600"), ("#0288D1", "700"), ("#0277BD", "800"), ("#01579B", "900"), ("#80D8FF", "A100"), ("#40C4FF", "A200"), ("#00B0FF", "A400"), ("#0091EA", "A700") ]), ("Cyan", [ ("#B2EBF2", "100"), ("#80DEEA", "200"), ("#4DD0E1", "300"), ("#26C6DA", "400"), ("#00BCD4", "500"), ("#00ACC1", "600"), ("#0097A7", "700"), ("#00838F", "800"), ("#006064", "900"), ("#84FFFF", "A100"), ("#18FFFF", "A200"), ("#00E5FF", "A400"), ("#00B8D4", "A700") ]), ("Teal", [ ("#B2DFDB", "100"), ("#80CBC4", "200"), ("#4DB6AC", "300"), ("#26A69A", "400"), ("#009688", "500"), ("#00897B", "600"), ("#00796B", "700"), ("#00695C", "800"), ("#004D40", "900"), ("#A7FFEB", "A100"), ("#64FFDA", "A200"), ("#1DE9B6", "A400"), ("#00BFA5", "A700") ]) ] fourth_row_palettes = [ ("Green", [ ("#C8E6C9", "100"), ("#A5D6A7", "200"), ("#81C784", "300"), ("#66BB6A", "400"), ("#4CAF50", "500"), ("#43A047", "600"), ("#388E3C", "700"), ("#2E7D32", "800"), ("#1B5E20", "900"), ("#B9F6CA", "A100"), ("#69F0AE", "A200"), ("#00E676", "A400"), ("#00C853", "A700") ]), ("Light Green", [ ("#DCEDC8", "100"), ("#C5E1A5", "200"), ("#AED581", "300"), ("#9CCC65", "400"), ("#8BC34A", "500"), ("#7CB342", "600"), ("#689F38", "700"), ("#558B2F", "800"), ("#33691E", "900"), ("#CCFF90", "A100"), ("#B2FF59", "A200"), ("#76FF03", "A400"), ("#64DD17", "A700") ]), ("Lime", [ ("#F0F4C3", "100"), ("#E6EE9C", "200"), ("#DCE775", "300"), ("#D4E157", "400"), ("#CDDC39", "500"), ("#C0CA33", "600"), ("#AFB42B", "700"), ("#9E9D24", "800"), ("#827717", "900"), ("#F4FF81", "A100"), ("#EEFF41", "A200"), ("#C6FF00", "A400"), ("#AEEA00", "A700") ]) ] fifth_row_palettes = [ ("Yellow", [ ("#FFF9C4", "100"), ("#FFF59D", "200"), ("#FFF176", "300"), ("#FFEE58", "400"), ("#FFEB3B", "500"), ("#FDD835", "600"), ("#FBC02D", "700"), ("#F9A825", "800"), ("#F57F17", "900"), ("#FFFF8D", "A100"), ("#FFFF00", "A200"), ("#FFEA00", "A400"), ("#FFD600", "A700") ]), ("Amber", [ ("#FFECB3", "100"), ("#FFE082", "200"), ("#FFD54F", "300"), ("#FFCA28", "400"), ("#FFC107", "500"), ("#FFB300", "600"), ("#FFA000", "700"), ("#FF8F00", "800"), ("#FF6F00", "900"), ("#FFE57F", "A100"), ("#FFD740", "A200"), ("#FFC400", "A400"), ("#FFAB00", "A700") ]), ("Orange", [ ("#FFE0B2", "100"), ("#FFCC80", "200"), ("#FFB74D", "300"), ("#FFA726", "400"), ("#FF9800", "500"), ("#FB8C00", "600"), ("#F57C00", "700"), ("#EF6C00", "800"), ("#E65100", "900"), ("#FFD180", "A100"), ("#FFAB40", "A200"), ("#FF9100", "A400"), ("#FF6D00", "A700") ]) ] for row_palettes in [first_row_palettes, second_row_palettes, third_row_palettes, fourth_row_palettes, fifth_row_palettes]: row_layout = QHBoxLayout() row_layout.setSpacing(15) for palette_name, colors in row_palettes: column_layout = QVBoxLayout() column_layout.setSpacing(3) palette_label = QLabel(palette_name) palette_label.setStyleSheet("margin-bottom: 2px;") palette_label.setAlignment(Qt.AlignmentFlag.AlignCenter) column_layout.addWidget(palette_label) color_buttons_layout = QHBoxLayout() color_buttons_layout.setSpacing(3) for color_hex, color_name in colors: color_btn = QPushButton() color_btn.setFixedSize(18, 18) is_current = color_hex == self.current_theme_color border_style = "2px solid #fff" if is_current else "none" color_btn.setStyleSheet(f""" QPushButton {{ background-color: {color_hex}; border: {border_style}; border-radius: 9px; }} QPushButton:hover {{ border: 2px solid #fff; }} QPushButton:pressed {{ border: 2px solid #fff; }} """) color_btn.setCursor(Qt.CursorShape.PointingHandCursor) color_btn.setToolTip(f"{palette_name} {color_name}\n{color_hex}") color_btn.clicked.connect(lambda checked, color=color_hex, btn=color_btn: self.change_theme_color(color, btn)) self.color_buttons[color_hex] = color_btn color_buttons_layout.addWidget(color_btn) column_layout.addLayout(color_buttons_layout) row_layout.addLayout(column_layout) grid_layout.addLayout(row_layout) theme_layout.addLayout(grid_layout) theme_layout.addStretch() theme_tab.setLayout(theme_layout) self.tab_widget.addTab(theme_tab, "Theme") def change_theme_color(self, color, clicked_btn=None): if hasattr(self, 'color_buttons'): for color_hex, btn in self.color_buttons.items(): if color_hex == self.current_theme_color: btn.setStyleSheet(f""" QPushButton {{ background-color: {color_hex}; border: none; border-radius: 9px; }} QPushButton:hover {{ border: 2px solid #fff; }} QPushButton:pressed {{ border: 2px solid #fff; }} """) break self.current_theme_color = color self.settings.setValue('theme_color', color) self.settings.sync() if clicked_btn: clicked_btn.setStyleSheet(f""" QPushButton {{ background-color: {color}; border: 2px solid #fff; border-radius: 9px; }} QPushButton:hover {{ border: 2px solid #fff; }} QPushButton:pressed {{ border: 2px solid #fff; }} """) qdarktheme.setup_theme( custom_colors={ "[dark]": { "primary": color, } } ) def setup_about_tab(self): about_tab = QWidget() about_layout = QVBoxLayout() about_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) about_layout.setSpacing(15) sections = [ ("Check for Updates", "Check", "https://github.com/afkarxyz/SpotiFLAC/releases"), ("Report an Issue", "Report", "https://github.com/afkarxyz/SpotiFLAC/issues") ] for title, button_text, url in sections: section_widget = QWidget() section_layout = QVBoxLayout(section_widget) section_layout.setSpacing(10) section_layout.setContentsMargins(0, 0, 0, 0) label = QLabel(title) label.setStyleSheet("color: palette(text); font-weight: bold;") label.setAlignment(Qt.AlignmentFlag.AlignCenter) section_layout.addWidget(label) button = QPushButton(button_text) button.setFixedSize(120, 25) button.setCursor(Qt.CursorShape.PointingHandCursor) button.clicked.connect(lambda _, url=url: QDesktopServices.openUrl(QUrl(url if url.startswith(('http://', 'https://')) else f'https://{url}'))) section_layout.addWidget(button, alignment=Qt.AlignmentFlag.AlignCenter) about_layout.addWidget(section_widget) footer_label = QLabel(f"v{self.current_version} | October 2025") about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter) about_tab.setLayout(about_layout) self.tab_widget.addTab(about_tab, "About") def on_service_changed(self, index): service = self.service_dropdown.currentData() self.service = service self.settings.setValue('service', service) self.settings.sync() self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}") def save_url(self): self.settings.setValue('spotify_url', self.spotify_url.text().strip()) self.settings.sync() def save_filename_format(self): if self.artist_title_radio.isChecked(): self.filename_format = "artist_title" elif self.title_only_radio.isChecked(): self.filename_format = "title_only" else: self.filename_format = "title_artist" self.settings.setValue('filename_format', self.filename_format) self.settings.sync() def save_track_numbering(self): self.use_track_numbers = self.track_number_checkbox.isChecked() self.settings.setValue('use_track_numbers', self.use_track_numbers) 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): self.use_album_subfolders = self.album_subfolder_checkbox.isChecked() self.settings.setValue('use_album_subfolders', self.use_album_subfolders) self.settings.sync() def save_track_list_format(self): format_value = self.track_list_format_dropdown.currentData() self.track_list_format = format_value self.settings.setValue('track_list_format', format_value) self.settings.sync() if self.tracks: self.update_track_list_display() def save_date_format(self): format_value = self.date_format_dropdown.currentData() self.date_format = format_value self.settings.setValue('date_format', format_value) self.settings.sync() if self.tracks: self.update_track_list_display() def save_settings(self): self.settings.setValue('output_path', self.output_dir.text().strip()) self.settings.sync() self.log_output.append("Settings saved successfully!") def update_timer(self): self.elapsed_time = self.elapsed_time.addSecs(1) self.time_label.setText(self.elapsed_time.toString("hh:mm:ss")) def fetch_tracks(self): url = self.spotify_url.text().strip() if not url: self.log_output.append('Warning: Please enter a Spotify URL.') return try: self.reset_state() self.reset_ui() self.log_output.append('Just a moment. Fetching metadata...') self.tab_widget.setCurrentWidget(self.process_tab) self.metadata_worker = MetadataFetchWorker(url) self.metadata_worker.finished.connect(self.on_metadata_fetched) self.metadata_worker.error.connect(self.on_metadata_error) self.metadata_worker.start() except Exception as e: self.log_output.append(f'Error: Failed to start metadata fetch: {str(e)}') def on_metadata_fetched(self, metadata): try: url_info = parse_uri(self.spotify_url.text().strip()) if url_info["type"] == "track": self.handle_track_metadata(metadata["track"]) elif url_info["type"] == "album": self.handle_album_metadata(metadata) elif url_info["type"] == "playlist": self.handle_playlist_metadata(metadata) elif url_info["type"] == "artist_discography": self.handle_discography_metadata(metadata) elif url_info["type"] == "artist": self.handle_artist_metadata(metadata) self.update_button_states() self.tab_widget.setCurrentIndex(0) except Exception as e: self.log_output.append(f'Error: {str(e)}') def on_metadata_error(self, error_message): self.log_output.append(f'Error: {error_message}') def handle_track_metadata(self, track_data): track_id = track_data["external_urls"].split("/")[-1] track = Track( external_urls=track_data["external_urls"], title=track_data["name"], artists=track_data["artists"], album=track_data["album_name"], track_number=1, duration_ms=track_data.get("duration_ms", 0), id=track_id, isrc=track_data.get("isrc", ""), release_date=track_data.get("release_date", "") ) self.tracks = [track] self.all_tracks = [track] self.is_single_track = True self.is_album = self.is_playlist = False self.album_or_playlist_name = f"{self.tracks[0].title} - {self.tracks[0].artists}" metadata = { 'title': track_data["name"], 'artists': track_data["artists"], 'releaseDate': track_data["release_date"], 'cover': track_data["images"], 'duration_ms': track_data.get("duration_ms", 0) } self.update_display_after_fetch(metadata) def handle_album_metadata(self, album_data): self.album_or_playlist_name = album_data["album_info"]["name"] self.tracks = [] for track in album_data["track_list"]: track_id = track["external_urls"].split("/")[-1] self.tracks.append(Track( external_urls=track["external_urls"], title=track["name"], artists=track["artists"], album=self.album_or_playlist_name, track_number=track["track_number"], duration_ms=track.get("duration_ms", 0), id=track_id, isrc=track.get("isrc", ""), release_date=track.get("release_date", "") )) self.all_tracks = self.tracks.copy() self.is_album = True self.is_playlist = self.is_single_track = False metadata = { 'title': album_data["album_info"]["name"], 'artists': album_data["album_info"]["artists"], 'releaseDate': album_data["album_info"]["release_date"], 'cover': album_data["album_info"]["images"], 'total_tracks': album_data["album_info"]["total_tracks"] } self.update_display_after_fetch(metadata) def handle_playlist_metadata(self, playlist_data): self.album_or_playlist_name = playlist_data["playlist_info"]["owner"]["name"] self.tracks = [] for track in playlist_data["track_list"]: track_id = track["external_urls"].split("/")[-1] self.tracks.append(Track( external_urls=track["external_urls"], title=track["name"], artists=track["artists"], album=track["album_name"], track_number=track.get("track_number", len(self.tracks) + 1), duration_ms=track.get("duration_ms", 0), id=track_id, isrc=track.get("isrc", ""), release_date=track.get("release_date", "") )) self.all_tracks = self.tracks.copy() self.is_playlist = True self.is_album = self.is_single_track = False metadata = { 'title': playlist_data["playlist_info"]["owner"]["name"], 'artists': playlist_data["playlist_info"]["owner"]["display_name"], 'cover': playlist_data["playlist_info"]["owner"]["images"], 'followers': playlist_data["playlist_info"]["followers"]["total"], 'total_tracks': playlist_data["playlist_info"]["tracks"]["total"] } self.update_display_after_fetch(metadata) def handle_discography_metadata(self, discography_data): artist_info = discography_data["artist_info"] self.album_or_playlist_name = f"{artist_info['name']} - Discography ({artist_info['discography_type'].title()})" self.tracks = [] for track in discography_data["track_list"]: track_id = track["external_urls"].split("/")[-1] if track.get("external_urls") else "" self.tracks.append(Track( external_urls=track.get("external_urls", ""), title=track["name"], artists=track["artists"], album=track["album_name"], track_number=track.get("track_number", len(self.tracks) + 1), duration_ms=track.get("duration_ms", 0), id=track_id, isrc=track.get("isrc", ""), release_date=track.get("release_date", "") )) self.all_tracks = self.tracks.copy() self.is_playlist = True self.is_album = self.is_single_track = False metadata = { 'title': f"{artist_info['name']} - Discography", 'artists': f"{artist_info['discography_type'].title()} • {artist_info['total_albums']} albums", 'cover': artist_info["images"], 'followers': artist_info.get("followers", 0), 'total_tracks': len(self.tracks), 'discography_type': artist_info['discography_type'] } self.update_display_after_fetch(metadata) def handle_artist_metadata(self, artist_data): self.reset_state() metadata = { 'title': artist_data["artist"]["name"], 'artists': f"Followers: {artist_data['artist']['followers']:,}", 'cover': artist_data["artist"]["images"], 'followers': artist_data["artist"]["followers"], 'genres': artist_data["artist"].get("genres", []) } self.update_info_widget_artist_only(metadata) def update_display_after_fetch(self, metadata): self.track_list.setVisible(not self.is_single_track) if not self.is_single_track: self.search_widget.show() self.update_track_list_display() else: self.search_widget.hide() self.update_info_widget(metadata) def update_info_widget(self, metadata): self.title_label.setText(metadata['title']) if self.is_single_track or self.is_album: artists = metadata['artists'] if isinstance(metadata['artists'], list) else metadata['artists'].split(", ") label_text = "Artists" if len(artists) > 1 else "Artist" artists_text = ", ".join(artists) self.artists_label.setText(f"{label_text} {artists_text}") else: self.artists_label.setText(f"Owner {metadata['artists']}") if self.is_playlist and 'followers' in metadata: self.followers_label.setText(f"Followers {metadata['followers']:,}") self.followers_label.show() else: self.followers_label.hide() if metadata.get('releaseDate'): try: release_date = metadata['releaseDate'] if len(release_date) == 4: date_obj = datetime.strptime(release_date, "%Y") elif len(release_date) == 7: date_obj = datetime.strptime(release_date, "%Y-%m") else: date_obj = datetime.strptime(release_date, "%Y-%m-%d") formatted_date = date_obj.strftime("%d-%m-%Y") self.release_date_label.setText(f"Released {formatted_date}") self.release_date_label.show() except ValueError: self.release_date_label.setText(f"Released {metadata['releaseDate']}") self.release_date_label.show() else: self.release_date_label.hide() if self.is_single_track: duration = self.format_duration(metadata.get('duration_ms', 0)) self.type_label.setText(f"Duration {duration}") elif self.is_album: total_tracks = metadata.get('total_tracks', 0) self.type_label.setText(f"Album • {total_tracks} tracks") elif self.is_playlist: total_tracks = metadata.get('total_tracks', 0) if metadata.get('discography_type'): discography_type = metadata['discography_type'].title() self.type_label.setText(f"Discography ({discography_type}) • {total_tracks} tracks") else: self.type_label.setText(f"Playlist • {total_tracks} tracks") self.network_manager.get(QNetworkRequest(QUrl(metadata['cover']))) self.info_widget.show() def update_info_widget_artist_only(self, metadata): self.title_label.setText(metadata['title']) self.artists_label.setText(f"Followers {metadata['followers']:,}") if metadata.get('genres'): genres_text = ", ".join(metadata['genres'][:3]) if len(metadata['genres']) > 3: genres_text += f" (+{len(metadata['genres']) - 3} more)" self.followers_label.setText(f"Genres {genres_text}") self.followers_label.show() else: self.followers_label.hide() self.release_date_label.hide() self.type_label.setText("Artist Profile • No tracks available for download") self.network_manager.get(QNetworkRequest(QUrl(metadata['cover']))) self.track_list.hide() self.search_widget.hide() self.hide_track_buttons() self.info_widget.show() def reset_info_widget(self): self.title_label.clear() self.artists_label.clear() self.followers_label.clear() self.release_date_label.clear() self.type_label.clear() self.cover_label.clear() self.info_widget.hide() def on_cover_loaded(self, reply): if reply.error() == QNetworkReply.NetworkError.NoError: data = reply.readAll() pixmap = QPixmap() pixmap.loadFromData(data) self.cover_label.setPixmap(pixmap) def update_button_states(self): if self.is_single_track: for btn in [self.download_selected_btn, self.download_all_btn, self.remove_btn, self.clear_btn]: btn.hide() self.single_track_container.show() self.single_download_btn.setEnabled(True) self.single_clear_btn.setEnabled(True) else: self.single_track_container.hide() self.download_selected_btn.show() self.download_all_btn.show() self.remove_btn.show() self.clear_btn.show() self.download_all_btn.setText('Download All') self.clear_btn.setText('Clear') self.download_all_btn.setMinimumWidth(120) self.clear_btn.setMinimumWidth(120) self.download_selected_btn.setEnabled(True) self.download_all_btn.setEnabled(True) def hide_track_buttons(self): buttons = [ self.download_selected_btn, self.download_all_btn, self.remove_btn, self.clear_btn ] for btn in buttons: btn.hide() if hasattr(self, 'single_track_container'): self.single_track_container.hide() def download_selected(self): if self.is_single_track: self.download_all() else: selected_items = self.track_list.selectedItems() if not selected_items: self.log_output.append('Warning: Please select tracks to download.') return selected_indices = [self.track_list.row(item) for item in selected_items] self.download_tracks(selected_indices) def download_all(self): if self.is_single_track: self.download_tracks([0]) else: self.download_tracks(range(len(self.tracks))) def download_tracks(self, indices): self.log_output.clear() raw_outpath = self.output_dir.text().strip() outpath = os.path.normpath(raw_outpath) if not os.path.exists(outpath): self.log_output.append('Warning: Invalid output directory.') return tracks_to_download = self.tracks if self.is_single_track else [self.tracks[i] for i in indices] if self.is_album or self.is_playlist: name = self.album_or_playlist_name.strip() folder_name = re.sub(r'[<>:"/\\|?*]', '_', name) outpath = os.path.join(outpath, folder_name) os.makedirs(outpath, exist_ok=True) try: self.start_download_worker(tracks_to_download, outpath) except Exception as 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): service = self.service_dropdown.currentData() self.worker = DownloadWorker( tracks_to_download, outpath, self.is_single_track, self.is_album, self.is_playlist, self.album_or_playlist_name, self.filename_format, self.use_track_numbers, self.use_artist_subfolders, self.use_album_subfolders, service ) self.worker.finished.connect(lambda success, message, failed_tracks, successful_tracks, skipped_tracks: self.on_download_finished(success, message, failed_tracks, successful_tracks, skipped_tracks)) self.worker.progress.connect(self.update_progress) self.worker.start() self.start_timer() self.update_ui_for_download_start() def update_ui_for_download_start(self): self.download_selected_btn.setEnabled(False) self.download_all_btn.setEnabled(False) if hasattr(self, 'single_download_btn'): self.single_download_btn.setEnabled(False) if hasattr(self, 'single_clear_btn'): self.single_clear_btn.setEnabled(False) self.stop_btn.show() self.pause_resume_btn.show() self.progress_bar.show() self.progress_bar.setValue(0) self.tab_widget.setCurrentWidget(self.process_tab) def update_progress(self, message, percentage): self.log_output.append(message) self.log_output.moveCursor(QTextCursor.MoveOperation.End) if percentage > 0: self.progress_bar.setValue(percentage) def stop_download(self): if hasattr(self, 'worker'): self.worker.stop() self.stop_timer() self.on_download_finished(True, "Download stopped by user.", [], [], []) def on_download_finished(self, success, message, failed_tracks, successful_tracks=None, skipped_tracks=None): self.progress_bar.hide() self.stop_btn.hide() self.pause_resume_btn.hide() self.pause_resume_btn.setText('Pause') self.stop_timer() if successful_tracks is not None: self.successful_downloads = successful_tracks if skipped_tracks is not None: self.skipped_downloads = skipped_tracks if (hasattr(self, 'successful_downloads') and self.successful_downloads) or (hasattr(self, 'skipped_downloads') and self.skipped_downloads): self.remove_successful_btn.show() else: self.remove_successful_btn.hide() self.download_selected_btn.setEnabled(True) self.download_all_btn.setEnabled(True) if hasattr(self, 'single_download_btn'): self.single_download_btn.setEnabled(True) if hasattr(self, 'single_clear_btn'): self.single_clear_btn.setEnabled(True) if success: self.log_output.append(f"\nStatus: {message}") if failed_tracks: self.log_output.append("\nFailed downloads:") for title, artists, error in failed_tracks: self.log_output.append(f"• {title} - {artists}") self.log_output.append(f" Error: {error}\n") else: self.log_output.append(f"Error: {message}") self.tab_widget.setCurrentWidget(self.process_tab) def toggle_pause_resume(self): if hasattr(self, 'worker'): if self.worker.is_paused: self.worker.resume() self.pause_resume_btn.setText('Pause') self.timer.start(1000) else: self.worker.pause() self.pause_resume_btn.setText('Resume') def remove_successful_downloads(self): successful_tracks = getattr(self, 'successful_downloads', []) skipped_tracks = getattr(self, 'skipped_downloads', []) if not successful_tracks and not skipped_tracks: self.log_output.append("No downloaded or skipped tracks to remove.") return tracks_to_remove = [] for track in self.tracks: for successful_track in successful_tracks: if (track.title == successful_track.title and track.artists == successful_track.artists and track.album == successful_track.album): tracks_to_remove.append(track) break for track in self.tracks: for skipped_track in skipped_tracks: if (track.title == skipped_track.title and track.artists == skipped_track.artists and track.album == skipped_track.album): if track not in tracks_to_remove: tracks_to_remove.append(track) break if tracks_to_remove: for track in tracks_to_remove: if track in self.tracks: self.tracks.remove(track) if track in self.all_tracks: self.all_tracks.remove(track) self.update_track_list_display() successful_count = len([t for t in tracks_to_remove if t in successful_tracks]) skipped_count = len([t for t in tracks_to_remove if t in skipped_tracks]) message = f"Removed {len(tracks_to_remove)} tracks from the list" if successful_count > 0: message += f" ({successful_count} downloaded" if skipped_count > 0: message += f", {skipped_count} already existed" if successful_count > 0 else f" ({skipped_count} already existed" if successful_count > 0 or skipped_count > 0: message += ")" self.log_output.append(message + ".") self.tab_widget.setCurrentIndex(0) else: self.log_output.append("No matching tracks found in the current list.") self.remove_successful_btn.hide() def remove_selected_tracks(self): if not self.is_single_track: selected_items = self.track_list.selectedItems() selected_indices = [self.track_list.row(item) for item in selected_items] tracks_to_remove = [self.tracks[i] for i in selected_indices] for track in tracks_to_remove: if track in self.tracks: self.tracks.remove(track) if track in self.all_tracks: self.all_tracks.remove(track) self.update_track_list_display() def clear_tracks(self): self.reset_state() self.reset_ui() self.tab_widget.setCurrentIndex(0) def start_timer(self): self.elapsed_time = QTime(0, 0, 0) self.time_label.setText("00:00:00") self.time_label.show() self.timer.start(1000) def stop_timer(self): self.timer.stop() self.time_label.hide() def closeEvent(self, event): if hasattr(self, 'timer'): self.timer.stop() if hasattr(self, 'service_dropdown'): for attr_name in ['tidal_status_checker', 'deezer_status_checker']: if hasattr(self.service_dropdown, attr_name): checker = getattr(self.service_dropdown, attr_name) if checker.isRunning(): checker.quit() checker.wait() if hasattr(self, 'worker') and self.worker and self.worker.isRunning(): self.worker.stop() self.worker.quit() self.worker.wait() event.accept() if __name__ == '__main__': try: if sys.platform == "win32": import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') except Exception as e: pass app = QApplication(sys.argv) settings = QSettings('SpotiFLAC', 'Settings') theme_color = settings.value('theme_color', '#2196F3') qdarktheme.setup_theme( custom_colors={ "[dark]": { "primary": theme_color, } } ) ex = SpotiFLACGUI() ex.show() sys.exit(app.exec())