import sys
import os
from dataclasses import dataclass
from datetime import datetime
import requests
import re
from packaging import version
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit,
QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton,
QAbstractItemView, QSpacerItem, QSizePolicy, QProgressBar, QCheckBox, QDialog,
QDialogButtonBox, QComboBox, QStyledItemDelegate, QStyle
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer, QTime, QSettings, QSize
from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush, QPalette
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
from getTracks import TrackDownloader
@dataclass
class Track:
external_urls: str
title: str
artists: str
album: str
track_number: int
duration_ms: int
id: 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)
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_album_subfolders=False, use_fallback=False, service="amazon", timeout=30):
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_album_subfolders = use_album_subfolders
self.use_fallback = use_fallback
self.service = service
self.timeout = timeout
self.is_paused = False
self.is_stopped = False
self.failed_tracks = []
def get_formatted_filename(self, track):
if self.filename_format == "artist_title":
filename = f"{track.artists} - {track.title}.flac"
else:
filename = f"{track.title} - {track.artists}.flac"
return re.sub(r'[<>:"/\\|?*]', '_', filename)
def run(self):
try:
downloader = TrackDownloader(self.use_fallback, self.timeout)
def progress_update(current, total):
if total > 0:
percent = (current / total) * 100
current_mb = current / (1024 * 1024)
total_mb = total / (1024 * 1024)
self.progress.emit(f"Download progress: {percent:.2f}% ({current_mb:.2f}MB/{total_mb:.2f}MB)",
int(percent))
else:
self.progress.emit(f"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:
track_id = track.id
self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0)
if self.is_playlist and self.use_album_subfolders:
album_folder = re.sub(r'[<>:"/\\|?*]', '_', track.album)
track_outpath = os.path.join(self.outpath, album_folder)
os.makedirs(track_outpath, exist_ok=True)
else:
track_outpath = self.outpath
import asyncio
metadata = asyncio.run(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_album or (self.is_playlist and self.use_album_subfolders)) 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'[<>:"/\\|?*]', '_', new_filename)
new_filepath = os.path.join(track_outpath, new_filename)
if os.path.exists(downloaded_file) and downloaded_file != new_filepath:
if os.path.exists(new_filepath):
os.remove(new_filepath)
os.rename(downloaded_file, new_filepath)
self.progress.emit(f"File renamed to: {new_filename}", 0)
self.progress.emit(f"Successfully downloaded: {track.title} - {track.artists}",
int((i + 1) / total_tracks * 100))
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"
self.finished.emit(True, success_message, self.failed_tracks)
except Exception as e:
self.finished.emit(False, str(e), self.failed_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 Available")
self.setFixedWidth(400)
self.setModal(True)
layout = QVBoxLayout()
message = QLabel(f"A new version of SpotiFLAC is available!\n\n"
f"Current version: v{current_version}\n"
f"New version: v{new_version}")
message.setWordWrap(True)
layout.addWidget(message)
self.disable_check = QCheckBox("Turn off update checking")
self.disable_check.setCursor(Qt.CursorShape.PointingHandCursor)
layout.addWidget(self.disable_check)
button_box = QDialogButtonBox()
self.update_button = QPushButton("Update")
self.update_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.cancel_button = QPushButton("Cancel")
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 ServiceStatusChecker(QThread):
status_updated = pyqtSignal(dict)
error = pyqtSignal(str)
def run(self):
try:
response = requests.get("https://lucida.to/api/stats", timeout=5)
if response.status_code == 200:
data = response.json()
services_status = {}
current_services = data.get('all', {}).get('downloads', {}).get('current', {}).get('services', {})
services_status['amazon'] = current_services.get('amazon', 0) > 0
services_status['tidal'] = current_services.get('tidal', 0) > 0
services_status['deezer'] = current_services.get('deezer', 0) > 0
self.status_updated.emit(services_status)
else:
self.error.emit(f"Server returned status code: {response.status_code}")
except Exception as e:
self.error.emit(f"Error checking service status: {str(e)}")
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)
if option.state & QStyle.StateFlag.State_Selected:
text_color = option.palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText)
else:
text_color = option.palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.Text)
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 - 10
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.status_checker = ServiceStatusChecker()
self.status_checker.status_updated.connect(self.update_service_status)
self.status_checker.error.connect(lambda e: print(f"Status check error: {e}"))
self.status_checker.start()
self.status_timer = QTimer(self)
self.status_timer.timeout.connect(self.refresh_status)
self.status_timer.start(5000)
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': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.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, status_dict):
self.services_status = status_dict
for i in range(self.count()):
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
if service_id in self.services_status:
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
if isinstance(service_data, dict):
service_data['online'] = self.services_status[service_id]
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
self.update()
def refresh_status(self):
self.status_checker = ServiceStatusChecker()
self.status_checker.status_updated.connect(self.update_service_status)
self.status_checker.error.connect(lambda e: print(f"Status check error: {e}"))
self.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 = "2.6"
self.tracks = []
self.reset_state()
self.settings = QSettings('SpotiFLAC', 'Settings')
self.last_output_path = self.settings.value('output_path', os.path.expanduser("~\\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_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool)
self.use_fallback = self.settings.value('use_fallback', False, type=bool)
self.service = self.settings.value('service', 'amazon')
self.timeout_value = self.settings.value('timeout_value', 30, type=int)
self.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
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 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 dialog.disable_check.isChecked():
self.settings.setValue('check_for_updates', False)
self.check_for_updates = False
if result == QDialog.DialogCode.Accepted:
QDesktopServices.openUrl(QUrl("https://github.com/afkarxyz/SpotiFLAC/releases"))
except Exception as e:
print(f"Error checking for updates: {e}")
@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.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()
def initUI(self):
self.setWindowTitle('SpotiFLAC')
self.setFixedWidth(650)
self.setFixedHeight(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("Please enter the 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.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 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_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_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.info_widget.setLayout(info_layout)
self.info_widget.setFixedHeight(100)
self.info_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.setFixedWidth(150)
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)
self.btn_layout.addStretch()
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.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)
control_layout.addWidget(self.stop_btn)
control_layout.addWidget(self.pause_resume_btn)
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()
def setup_settings_tab(self):
settings_tab = QWidget()
settings_layout = QVBoxLayout()
settings_layout.setSpacing(10)
settings_layout.setContentsMargins(9, 9, 9, 9)
output_group = QWidget()
output_layout = QVBoxLayout(output_group)
output_layout.setSpacing(5)
output_label = QLabel('Output Directory')
output_label.setStyleSheet("font-weight: bold;")
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.setCursor(Qt.CursorShape.PointingHandCursor)
self.output_browse.clicked.connect(self.browse_output)
output_dir_layout.addWidget(self.output_dir)
output_dir_layout.addWidget(self.output_browse)
output_layout.addLayout(output_dir_layout)
settings_layout.addWidget(output_group)
file_group = QWidget()
file_layout = QVBoxLayout(file_group)
file_layout.setSpacing(5)
file_label = QLabel('File Settings')
file_label.setStyleSheet("font-weight: bold;")
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)
if hasattr(self, 'filename_format') and self.filename_format == "artist_title":
self.artist_title_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)
format_layout.addWidget(format_label)
format_layout.addWidget(self.title_artist_radio)
format_layout.addWidget(self.artist_title_radio)
format_layout.addStretch()
file_layout.addLayout(format_layout)
checkbox_layout = QHBoxLayout()
self.track_number_checkbox = QCheckBox('Add Track Numbers to Album Files')
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)
self.album_subfolder_checkbox = QCheckBox('Create Album Subfolders for Playlist Downloads')
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.addStretch()
file_layout.addLayout(checkbox_layout)
settings_layout.addWidget(file_group)
auth_group = QWidget()
auth_layout = QVBoxLayout(auth_group)
auth_layout.setSpacing(5)
auth_label = QLabel('Lucida Settings')
auth_label.setStyleSheet("font-weight: bold;")
auth_layout.addWidget(auth_label)
service_fallback_layout = QHBoxLayout()
service_label = QLabel('Service:')
self.service_dropdown = ServiceComboBox()
self.service_dropdown.currentIndexChanged.connect(self.save_service_setting)
service_fallback_layout.addWidget(service_label)
service_fallback_layout.addWidget(self.service_dropdown)
service_fallback_layout.addSpacing(20)
self.fallback_checkbox = QCheckBox('Fallback')
self.fallback_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
self.fallback_checkbox.setChecked(self.use_fallback)
self.fallback_checkbox.toggled.connect(self.save_fallback_setting)
service_fallback_layout.addWidget(self.fallback_checkbox)
service_fallback_layout.addSpacing(20)
timeout_label = QLabel('Timeout:')
self.timeout_input = QLineEdit()
self.timeout_input.setText(str(self.timeout_value))
self.timeout_input.setFixedWidth(60)
self.timeout_input.textChanged.connect(self.save_timeout_setting)
service_fallback_layout.addWidget(timeout_label)
service_fallback_layout.addWidget(self.timeout_input)
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")
def setup_about_tab(self):
about_tab = QWidget()
about_layout = QVBoxLayout()
about_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
about_layout.setSpacing(3)
sections = [
("Check for Updates", "https://github.com/afkarxyz/SpotiFLAC/releases"),
("Report an Issue", "https://github.com/afkarxyz/SpotiFLAC/issues"),
("Lucida Site", "https://lucida.to/stats")
]
for title, 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("Click Here!")
button.setFixedWidth(150)
button.setStyleSheet("""
QPushButton {
background-color: palette(button);
color: palette(button-text);
border: 1px solid palette(mid);
padding: 6px;
border-radius: 15px;
}
QPushButton:hover {
background-color: palette(light);
}
QPushButton:pressed {
background-color: palette(midlight);
}
""")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _, url=url: QDesktopServices.openUrl(QUrl(url)))
section_layout.addWidget(button, alignment=Qt.AlignmentFlag.AlignCenter)
about_layout.addWidget(section_widget)
if sections.index((title, url)) < len(sections) - 1:
spacer = QSpacerItem(20, 6, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
about_layout.addItem(spacer)
footer_label = QLabel("v2.6 | May 2025")
footer_label.setStyleSheet("font-size: 12px; margin-top: 10px;")
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
about_tab.setLayout(about_layout)
self.tab_widget.addTab(about_tab, "About")
def save_url(self):
self.settings.setValue('spotify_url', self.spotify_url.text().strip())
self.settings.sync()
def save_filename_format(self):
self.filename_format = "artist_title" if self.artist_title_radio.isChecked() else "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_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_fallback_setting(self):
self.use_fallback = self.fallback_checkbox.isChecked()
self.settings.setValue('use_fallback', self.use_fallback)
self.settings.sync()
self.log_output.append("Fallback setting saved successfully!")
def save_timeout_setting(self):
try:
timeout = int(self.timeout_input.text())
if timeout > 0:
self.timeout_value = timeout
self.settings.setValue('timeout_value', self.timeout_value)
self.settings.sync()
self.log_output.append(f"Timeout setting saved: {self.timeout_value} seconds")
else:
self.timeout_input.setText(str(self.timeout_value))
self.log_output.append("Timeout must be a positive number")
except ValueError:
self.timeout_input.setText(str(self.timeout_value))
self.log_output.append("Timeout must be a valid number")
def save_service_setting(self):
service = self.service_dropdown.currentData()
self.service = service
self.settings.setValue('service', service)
self.settings.sync()
self.log_output.append(f"Service setting saved: {self.service_dropdown.currentText()}")
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)
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]
self.tracks = [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
)]
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
))
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=len(self.tracks) + 1,
duration_ms=track.get("duration_ms", 0),
id=track_id
))
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 update_display_after_fetch(self, metadata):
self.track_list.setVisible(not self.is_single_track)
if not self.is_single_track:
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}")
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)
self.type_label.setText(f"Playlist • {total_tracks} tracks")
self.network_manager.get(QNetworkRequest(QUrl(metadata['cover'])))
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:
self.download_selected_btn.hide()
self.remove_btn.hide()
self.download_all_btn.setText('Download')
self.clear_btn.setText('Clear')
else:
self.download_selected_btn.show()
self.remove_btn.show()
self.download_all_btn.setText('Download All')
self.clear_btn.setText('Clear')
self.download_all_btn.show()
self.clear_btn.show()
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()
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
self.download_tracks([self.track_list.row(item) for item in selected_items])
def download_all(self):
if self.is_single_track:
self.download_tracks([0])
else:
self.download_tracks(range(self.track_list.count()))
def download_tracks(self, indices):
self.log_output.clear()
outpath = self.output_dir.text()
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:
folder_name = re.sub(r'[<>:"/\\|?*]', '_', self.album_or_playlist_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_album_subfolders,
self.use_fallback,
service,
self.timeout_value
)
self.worker.finished.connect(self.on_download_finished)
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)
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):
if "Download progress:" in message or "Processing metadata..." in message:
current_text = self.log_output.toPlainText()
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:
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):
self.progress_bar.hide()
self.stop_btn.hide()
self.pause_resume_btn.hide()
self.pause_resume_btn.setText('Pause')
self.stop_timer()
self.download_selected_btn.setEnabled(True)
self.download_all_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_selected_tracks(self):
if not self.is_single_track:
selected_indices = sorted([self.track_list.row(item) for item in self.track_list.selectedItems()], reverse=True)
for index in selected_indices:
self.track_list.takeItem(index)
self.tracks.pop(index)
for i, track in enumerate(self.tracks, 1):
if self.is_playlist:
track.track_number = i
duration = self.format_duration(track.duration_ms)
display_text = f"{i}. {track.title} - {track.artists} • {duration}"
list_item = self.track_list.item(i - 1)
if list_item:
list_item.setText(display_text)
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()
if __name__ == '__main__':
app = QApplication(sys.argv)
ex = SpotiFLACGUI()
ex.show()
sys.exit(app.exec())