diff --git a/README.md b/README.md
index 62b0612..766a1aa 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
SpotiFLAC allows you to download Spotify tracks in true FLAC format through services like Tidal, Amazon Music and Deezer with the help of Lucida.
-### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v2.1/SpotiFLAC.exe)
+### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v2.2/SpotiFLAC.exe)
#
diff --git a/SpotiFLAC.py b/SpotiFLAC.py
index 59a0542..e2c9c62 100644
--- a/SpotiFLAC.py
+++ b/SpotiFLAC.py
@@ -1,161 +1,187 @@
import sys
import os
-import requests
-import time
+from dataclasses import dataclass
from datetime import datetime
-from pathlib import Path
+import requests
+import re
from packaging import version
-from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
- QHBoxLayout, QLabel, QLineEdit, QPushButton,
- QProgressBar, QFileDialog, QCheckBox, QRadioButton,
- QGroupBox, QComboBox, QDialog, QDialogButtonBox,
- QStyledItemDelegate, QStyle)
-from PyQt6.QtCore import QThread, pyqtSignal, Qt, QSettings, QSize, QTimer, QUrl
-from PyQt6.QtGui import QIcon, QPixmap, QCursor, QDesktopServices, QBrush, QPalette
+
+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
-class ImageDownloader(QThread):
- finished = pyqtSignal(bytes)
+@dataclass
+class Track:
+ external_urls: str
+ title: str
+ artists: str
+ album: str
+ track_number: int
+ duration_ms: int
+ id: str
+
+class DownloadWorker(QThread):
+ finished = pyqtSignal(bool, str, list)
+ progress = pyqtSignal(str, int)
- def __init__(self, url):
+ 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"):
super().__init__()
- self.url = url
-
- def run(self):
- import requests
- response = requests.get(self.url)
- if response.status_code == 200:
- self.finished.emit(response.content)
-
-class MetadataFetcher(QThread):
- finished = pyqtSignal(dict)
- error = pyqtSignal(str)
-
- def __init__(self, url, service="amazon", use_fallback=False):
- super().__init__()
- self.url = url
- self.service = service
- self.use_fallback = use_fallback
- self.max_retries = 3
- self.downloader = TrackDownloader(use_fallback=use_fallback)
-
- def extract_track_id(self, url):
- if "track/" in url:
- return url.split("track/")[1].split("?")[0].split("/")[0]
- return None
-
- async def get_track_info_async(self, track_id, service, use_fallback):
- try:
- metadata = await self.downloader.get_track_info(track_id, service, use_fallback)
- return metadata
- except Exception as e:
- raise e
-
- def run(self):
- try:
- track_id = self.extract_track_id(self.url)
- if not track_id:
- self.error.emit("Invalid Spotify URL")
- return
-
- import asyncio
- for attempt in range(self.max_retries):
- try:
- metadata = asyncio.run(self.get_track_info_async(
- track_id, self.service, self.use_fallback))
-
- formatted_metadata = {
- 'title': metadata['title'],
- 'artists': metadata['artists'],
- 'cover': metadata['coverArtwork'],
- 'url': metadata['url'],
- 'token': metadata['token'],
- 'duration': metadata.get('durationMs', 0),
- 'release_date': metadata.get('releaseDate', '')
- }
-
- self.finished.emit(formatted_metadata)
- return
-
- except Exception as e:
- if attempt < self.max_retries - 1:
- time.sleep(2 * (attempt + 1))
- continue
- raise e
-
- except Exception as e:
- error_msg = str(e)
- if "refused" in error_msg.lower():
- self.error.emit("Connection refused. Please check your internet connection and try again.")
- elif "timeout" in error_msg.lower():
- self.error.emit("Connection timed out. Please check your internet connection and try again.")
- else:
- self.error.emit(f"Error: {error_msg}")
-
-class DownloaderWorker(QThread):
- progress = pyqtSignal(int)
- status = pyqtSignal(str)
- finished = pyqtSignal(str)
- error = pyqtSignal(str)
-
- def __init__(self, metadata, output_dir, filename_format='title_artist', use_fallback=False):
- super().__init__()
- self.metadata = metadata
- self.output_dir = output_dir
+ 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.downloader = TrackDownloader(use_fallback=use_fallback)
- self.last_update_time = 0
- self.last_downloaded_size = 0
-
- def format_size(self, size_bytes):
- units = ['B', 'KB', 'MB', 'GB']
- index = 0
- while size_bytes >= 1024 and index < len(units) - 1:
- size_bytes /= 1024
- index += 1
- return f"{size_bytes:.2f}{units[index]}"
-
- def format_speed(self, speed_bytes):
- speed_bits = speed_bytes * 8
-
- if speed_bits >= 1024 * 1024:
- speed_mbps = speed_bits / (1024 * 1024)
- return f"{speed_mbps:.2f}Mbps"
+ self.service = service
+ 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:
- speed_kbps = speed_bits / 1024
- return f"{speed_kbps:.2f}Kbps"
-
- def progress_callback(self, downloaded_size, total_size):
- current_time = time.time()
- if current_time - self.last_update_time >= 0.5:
- progress = int((downloaded_size / total_size) * 100) if total_size > 0 else 0
- self.progress.emit(progress)
-
- time_diff = current_time - self.last_update_time
- if time_diff > 0:
- speed = (downloaded_size - self.last_downloaded_size) / time_diff
- if downloaded_size == 0 and total_size == 0:
- status = "Preparing metadata..."
- else:
- status = f"Downloading... {self.format_size(downloaded_size)}/{self.format_size(total_size)} | {self.format_speed(speed)}"
- self.status.emit(status)
-
- self.last_update_time = current_time
- self.last_downloaded_size = downloaded_size
-
+ filename = f"{track.title} - {track.artists}.flac"
+ return re.sub(r'[<>:"/\\|?*]', '_', filename)
+
def run(self):
try:
- self.status.emit("Preparing...")
- self.downloader.set_progress_callback(self.progress_callback)
- self.downloader.set_filename_format(self.filename_format)
- self.progress.emit(0)
- downloaded_file = self.downloader.download(self.metadata, self.output_dir)
- self.progress.emit(100)
- self.finished.emit("Download complete!")
- except Exception as e:
- self.error.emit(f"Error: {str(e)}")
+ downloader = TrackDownloader(self.use_fallback)
+
+ def progress_update(current, total):
+ if total > 0:
+ percent = (current / total) * 100
+ self.progress.emit(f"Download progress: {percent:.2f}% ({current}/{total})",
+ int(percent))
+ else:
+ self.progress.emit(f"Processing metadata...", 0)
+
+ downloader.set_progress_callback(progress_update)
+ downloader.set_filename_format(self.filename_format)
+
+ 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)
+ downloaded_file = downloader.download(metadata, track_outpath)
+
+ 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)
@@ -179,7 +205,6 @@ class ServiceStatusChecker(QThread):
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)
@@ -204,7 +229,6 @@ class StatusIndicatorDelegate(QStyledItemDelegate):
painter.drawEllipse(circle_x, circle_y, circle_size, circle_size)
painter.restore()
-
class ServiceComboBox(QComboBox):
def __init__(self, parent=None):
super().__init__(parent)
@@ -228,8 +252,8 @@ class ServiceComboBox(QComboBox):
current_dir = os.path.dirname(os.path.abspath(__file__))
self.services = [
- {'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False},
{'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}
]
@@ -272,70 +296,33 @@ class ServiceComboBox(QComboBox):
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
return super().currentData(role)
-
-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 SpotiFlacGUI(QMainWindow):
+class SpotiFLACGUI(QWidget):
def __init__(self):
super().__init__()
- self.current_version = "2.1"
- self.settings = QSettings('SpotiFlac', 'Settings')
- self.setWindowTitle("SpotiFLAC")
+ self.current_version = "2.2"
+ 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.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
- icon_path = os.path.join(os.path.dirname(__file__), "icon.svg")
- if os.path.exists(icon_path):
- self.setWindowIcon(QIcon(icon_path))
+ self.elapsed_time = QTime(0, 0, 0)
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.update_timer)
- self.setFixedWidth(600)
- self.setFixedHeight(180)
+ self.network_manager = QNetworkAccessManager()
+ self.network_manager.finished.connect(self.on_cover_loaded)
- self.default_music_dir = str(Path.home() / "Music")
- if not os.path.exists(self.default_music_dir):
- os.makedirs(self.default_music_dir)
-
- self.metadata = None
- self.init_ui()
- self.url_input.textChanged.connect(self.validate_url)
- self.load_settings()
- self.setup_settings_persistence()
-
- last_url = self.settings.value('last_url', '')
- self.url_input.setText(last_url)
- self.url_input.textChanged.connect(self.save_url)
+ self.initUI()
if self.check_for_updates:
QTimer.singleShot(0, self.check_updates)
@@ -360,422 +347,781 @@ class SpotiFlacGUI(QMainWindow):
except Exception as e:
print(f"Error checking for updates: {e}")
-
- def load_settings(self):
- fallback = self.settings.value('fallback', False, type=bool)
- service = self.settings.value('service', 'amazon')
- format_type = self.settings.value('format', 'title_artist')
- output_dir = self.settings.value('output_dir', self.default_music_dir)
-
- self.fallback_checkbox.setChecked(fallback)
-
- for i in range(self.service_combo.count()):
- if self.service_combo.itemData(i, Qt.ItemDataRole.UserRole + 1) == service:
- self.service_combo.setCurrentIndex(i)
- break
-
- self.format_title_artist.setChecked(format_type == 'title_artist')
- self.format_artist_title.setChecked(format_type == 'artist_title')
- self.dir_input.setText(output_dir)
-
- def setup_settings_persistence(self):
- self.fallback_checkbox.stateChanged.connect(
- lambda x: self.settings.setValue('fallback', bool(x)))
- self.service_combo.currentIndexChanged.connect(
- lambda i: self.settings.setValue('service', self.service_combo.itemData(i, Qt.ItemDataRole.UserRole + 1)))
- self.format_title_artist.toggled.connect(
- lambda x: self.settings.setValue('format', 'title_artist' if x else 'artist_title'))
- self.dir_input.textChanged.connect(
- lambda x: self.settings.setValue('output_dir', x))
- def init_ui(self):
- central_widget = QWidget()
- self.setCentralWidget(central_widget)
- self.main_layout = QVBoxLayout(central_widget)
- self.main_layout.setContentsMargins(10, 10, 10, 10)
+ @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 = ''
- self.input_widget = QWidget()
- input_layout = QVBoxLayout(self.input_widget)
- input_layout.setSpacing(10)
+ 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()
- url_layout = QHBoxLayout()
- url_label = QLabel("Track URL:")
- url_label.setFixedWidth(100)
- self.url_input = QLineEdit()
- self.url_input.setPlaceholderText("Please enter track URL")
- self.url_input.setClearButtonEnabled(True)
- self.fetch_button = QPushButton("Fetch")
- self.fetch_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.fetch_button.setFixedWidth(100)
- self.fetch_button.setEnabled(False)
- self.fetch_button.clicked.connect(self.fetch_track_info)
- url_layout.addWidget(url_label)
- url_layout.addWidget(self.url_input)
- url_layout.addWidget(self.fetch_button)
- input_layout.addLayout(url_layout)
+ 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)
- dir_layout = QHBoxLayout()
- dir_label = QLabel("Output Directory:")
- dir_label.setFixedWidth(100)
- self.dir_input = QLineEdit(self.default_music_dir)
- self.dir_button = QPushButton("Browse")
- self.dir_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.dir_button.setFixedWidth(100)
- dir_layout.addWidget(dir_label)
- dir_layout.addWidget(self.dir_input)
- dir_layout.addWidget(self.dir_button)
- self.dir_button.clicked.connect(self.select_directory)
- input_layout.addLayout(dir_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)
- settings_group = QGroupBox("Settings")
- settings_layout = QHBoxLayout(settings_group)
- settings_layout.setContentsMargins(10, 0, 10, 10)
- settings_layout.setSpacing(10)
-
- settings_container = QWidget()
- settings_container_layout = QHBoxLayout(settings_container)
- settings_container_layout.setContentsMargins(0, 0, 0, 0)
- settings_container_layout.setSpacing(10)
-
- self.fallback_checkbox = QCheckBox("Fallback")
- self.fallback_checkbox.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.fallback_checkbox.setChecked(False)
- settings_container_layout.addWidget(self.fallback_checkbox)
-
- service_widget = QWidget()
- service_layout = QHBoxLayout(service_widget)
- service_layout.setContentsMargins(0, 0, 0, 0)
- service_layout.setSpacing(10)
-
- service_label = QLabel("Service:")
- self.service_combo = ServiceComboBox()
- self.service_combo.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
-
- service_layout.addWidget(service_label)
- service_layout.addWidget(self.service_combo)
-
- settings_container_layout.addWidget(service_widget)
-
- format_widget = QWidget()
- format_layout = QHBoxLayout(format_widget)
- format_layout.setContentsMargins(0, 0, 0, 0)
- format_layout.setSpacing(10)
-
- format_label = QLabel("Filename:")
- self.format_title_artist = QRadioButton("Title - Artist")
- self.format_artist_title = QRadioButton("Artist - Title")
- self.format_title_artist.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.format_artist_title.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.format_title_artist.setChecked(True)
-
- format_layout.addWidget(format_label)
- format_layout.addWidget(self.format_title_artist)
- format_layout.addWidget(self.format_artist_title)
-
- settings_container_layout.addWidget(format_widget)
-
- settings_layout.addStretch()
- settings_layout.addWidget(settings_container)
- settings_layout.addStretch()
-
- input_layout.addWidget(settings_group)
- self.main_layout.addWidget(self.input_widget)
+ def browse_output(self):
+ directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
+ if directory:
+ self.output_dir.setText(directory)
+ self.save_settings()
- self.track_widget = QWidget()
- self.track_widget.hide()
- track_layout = QHBoxLayout(self.track_widget)
- track_layout.setContentsMargins(0, 0, 0, 0)
- track_layout.setSpacing(10)
+ def setup_tabs(self):
+ self.tab_widget = QTabWidget()
+ self.main_layout.addWidget(self.tab_widget)
- cover_container = QWidget()
- cover_layout = QVBoxLayout(cover_container)
- cover_layout.setContentsMargins(0, 0, 0, 0)
- cover_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ 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(100, 100)
- self.cover_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- cover_layout.addWidget(self.cover_label)
- track_layout.addWidget(cover_container)
-
- track_details_container = QWidget()
- track_details_layout = QVBoxLayout(track_details_container)
- track_details_layout.setContentsMargins(0, 0, 0, 0)
- track_details_layout.setSpacing(2)
- track_details_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ 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.title_label.setMinimumWidth(400)
- self.artist_label = QLabel()
- self.artist_label.setStyleSheet("font-size: 12px;")
- self.artist_label.setWordWrap(True)
- self.artist_label.setMinimumWidth(400)
+ self.artists_label = QLabel()
+ self.artists_label.setWordWrap(True)
- track_details_layout.addWidget(self.title_label)
- track_details_layout.addWidget(self.artist_label)
- track_layout.addWidget(track_details_container, stretch=1)
- track_layout.addStretch()
- self.main_layout.addWidget(self.track_widget)
+ 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()
- self.download_button = QPushButton("Download")
- self.download_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.download_button.setFixedWidth(100)
- self.download_button.clicked.connect(self.button_clicked)
- self.download_button.hide()
+ info_layout.addLayout(text_info_layout, 1)
+ self.info_widget.setLayout(info_layout)
+ self.info_widget.setFixedHeight(100)
+ self.info_widget.hide()
- self.cancel_button = QPushButton("Cancel")
- self.cancel_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.cancel_button.setFixedWidth(100)
- self.cancel_button.clicked.connect(self.cancel_clicked)
- self.cancel_button.hide()
-
- self.open_button = QPushButton("Open")
- self.open_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.open_button.setFixedWidth(100)
- self.open_button.clicked.connect(self.open_output_directory)
- self.open_button.hide()
-
- download_layout = QHBoxLayout()
- download_layout.addStretch()
- download_layout.addWidget(self.open_button)
- download_layout.addWidget(self.download_button)
- download_layout.addWidget(self.cancel_button)
- download_layout.addStretch()
- self.main_layout.addLayout(download_layout)
+ 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.main_layout.addWidget(self.progress_bar)
+ self.time_label.hide()
+ self.stop_btn.hide()
+ self.pause_resume_btn.hide()
- bottom_layout = QHBoxLayout()
-
- self.status_label = QLabel("")
- bottom_layout.addWidget(self.status_label, stretch=1)
-
- self.update_button = QPushButton()
- icon_path = os.path.join(os.path.dirname(__file__), "update.svg")
- if os.path.exists(icon_path):
- self.update_button.setIcon(QIcon(icon_path))
- self.update_button.setFixedSize(16, 16)
- self.update_button.setStyleSheet("""
- QPushButton {
- border: none;
- background: transparent;
- }
- QPushButton:hover {
- background: transparent;
- }
- """)
- self.update_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
- self.update_button.setToolTip("Check for Updates")
- self.update_button.clicked.connect(self.open_update_page)
-
- bottom_layout.addWidget(self.update_button)
-
- self.main_layout.addLayout(bottom_layout)
+ def setup_settings_tab(self):
+ settings_tab = QWidget()
+ settings_layout = QVBoxLayout()
+ settings_layout.setSpacing(10)
+ settings_layout.setContentsMargins(9, 9, 9, 9)
- def save_url(self, url):
- self.settings.setValue('last_url', url)
- self.validate_url(url)
+ output_group = QWidget()
+ output_layout = QVBoxLayout(output_group)
+ output_layout.setSpacing(5)
+
+ output_label = QLabel('Output Directory')
+ output_label.setStyleSheet("font-weight: bold; color: palette(text);")
+ 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; color: palette(text);")
+ file_layout.addWidget(file_label)
+
+ format_layout = QHBoxLayout()
+ format_label = QLabel('Filename Format:')
+ format_label.setStyleSheet("color: palette(text);")
+
+ self.format_group = QButtonGroup(self)
+ self.title_artist_radio = QRadioButton('Title - Artist')
+ self.title_artist_radio.setStyleSheet("color: palette(text);")
+ 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.setStyleSheet("color: palette(text);")
+ 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.setStyleSheet("color: palette(text);")
+ 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.setStyleSheet("color: palette(text);")
+ 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')
+ auth_label.setStyleSheet("font-weight: bold; color: palette(text);")
+ auth_layout.addWidget(auth_label)
+
+ service_fallback_layout = QHBoxLayout()
+
+ service_label = QLabel('Service:')
+ service_label.setStyleSheet("color: palette(text);")
+
+ 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.setStyleSheet("color: palette(text);")
+ 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.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.2 | March 2025")
+ footer_label.setStyleSheet("font-size: 12px; color: palette(text); 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_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()
+
+ metadata = get_filtered_data(url)
+ if "error" in metadata:
+ raise Exception(metadata["error"])
- def open_update_page(self):
- import webbrowser
- webbrowser.open('https://github.com/afkarxyz/SpotiFLAC/releases')
-
- def validate_url(self, url):
- url = url.strip()
- self.fetch_button.setEnabled(False)
- if not url:
- self.status_label.clear()
- return
- if "open.spotify.com/" not in url:
- self.status_label.setText("Please enter a valid Spotify URL")
- return
- if "/album/" in url:
- self.status_label.setText("Album URLs are not supported. Please enter a track URL.")
- return
- if "/playlist/" in url:
- self.status_label.setText("Playlist URLs are not supported. Please enter a track URL.")
- return
- if "/track/" not in url:
- self.status_label.setText("Please enter a valid Spotify track URL")
- return
- self.fetch_button.setEnabled(True)
- self.status_label.clear()
+ url_info = parse_uri(url)
+
+ 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 SpotifyInvalidUrlException as e:
+ self.log_output.append(f'Error: {str(e)}')
+ except Exception as e:
+ self.log_output.append(f'Error: Failed to fetch metadata: {str(e)}')
- def fetch_track_info(self):
- url = self.url_input.text().strip()
- if not url:
- self.status_label.setText("Please enter a Track URL")
- return
- self.fetch_button.setEnabled(False)
- self.status_label.setText("Fetching track information...")
- fallback = self.fallback_checkbox.isChecked()
- service = self.service_combo.currentData()
- self.fetcher = MetadataFetcher(url, service=service, use_fallback=fallback)
- self.fetcher.finished.connect(self.handle_track_info)
- self.fetcher.error.connect(self.handle_fetch_error)
- self.fetcher.start()
+ 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_track_info(self, metadata):
- self.metadata = metadata
- self.fetch_button.setEnabled(True)
- self.title_label.setText(metadata['title'].strip())
+ def handle_album_metadata(self, album_data):
+ self.album_or_playlist_name = album_data["album_info"]["name"]
+ self.tracks = []
- artist_text = ""
+ 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
- artists_list = metadata['artists'].strip().split(",")
- if len(artists_list) > 1:
- artist_text += "Artists " + metadata['artists'].strip()
+ 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:
- artist_text += "Artist " + metadata['artists'].strip()
+ self.artists_label.setText(f"Owner {metadata['artists']}")
- if metadata.get('release_date'):
- try:
- date_obj = datetime.fromisoformat(metadata['release_date'].replace('Z', '+00:00'))
- formatted_date = date_obj.strftime("%d-%m-%Y")
- artist_text += f"
Released {formatted_date}"
- except:
- if metadata['release_date']:
- artist_text += f"
Released {metadata['release_date']}"
-
- if metadata.get('duration'):
- duration_ms = metadata['duration']
- minutes = int(duration_ms / 60000)
- seconds = int((duration_ms % 60000) / 1000)
- artist_text += f"
Duration {minutes}:{seconds:02d}"
-
- self.artist_label.setText(artist_text)
- self.artist_label.setTextFormat(Qt.TextFormat.RichText)
-
- self.image_downloader = ImageDownloader(metadata['cover'])
- self.image_downloader.finished.connect(self.update_cover_art)
- self.image_downloader.start()
-
- self.input_widget.hide()
- self.track_widget.show()
- self.download_button.show()
- self.cancel_button.show()
- self.update_button.hide()
- self.status_label.clear()
-
- def update_cover_art(self, image_data):
- pixmap = QPixmap()
- pixmap.loadFromData(image_data)
- scaled_pixmap = pixmap.scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
- self.cover_label.setPixmap(scaled_pixmap)
-
- def handle_fetch_error(self, error):
- self.fetch_button.setEnabled(True)
- self.status_label.setText(f"Error fetching track info: {error}")
-
- def select_directory(self):
- directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
- if directory:
- self.dir_input.setText(directory)
-
- def open_output_directory(self):
- output_dir = self.dir_input.text().strip() or self.default_music_dir
- os.startfile(output_dir)
-
- def cancel_clicked(self):
- self.track_widget.hide()
- self.input_widget.show()
- self.download_button.hide()
- self.cancel_button.hide()
- self.progress_bar.hide()
- self.progress_bar.setValue(0)
- self.status_label.clear()
- self.metadata = None
- self.fetch_button.setEnabled(True)
- self.update_button.show()
- self.setFixedHeight(180)
-
- def button_clicked(self):
- if self.download_button.text() == "Clear":
- self.clear_form()
+ if self.is_playlist and 'followers' in metadata:
+ self.followers_label.setText(f"Followers {metadata['followers']:,}")
+ self.followers_label.show()
else:
- self.start_download()
-
- def clear_form(self):
- self.settings.setValue('last_url', '')
- self.url_input.clear()
- self.progress_bar.hide()
- self.progress_bar.setValue(0)
- self.status_label.clear()
- self.download_button.setText("Download")
- self.download_button.hide()
- self.cancel_button.hide()
- self.open_button.hide()
- self.track_widget.hide()
- self.input_widget.show()
- self.metadata = None
- self.update_button.show()
- self.setFixedHeight(180)
-
- def start_download(self):
- output_dir = self.dir_input.text().strip()
- if not self.metadata:
- self.status_label.setText("Please fetch track information first")
- return
- if not output_dir:
- output_dir = self.default_music_dir
- self.dir_input.setText(output_dir)
+ self.followers_label.hide()
- self.download_button.hide()
- self.cancel_button.hide()
+ if metadata.get('releaseDate'):
+ release_date = datetime.strptime(metadata['releaseDate'], "%Y-%m-%d")
+ formatted_date = release_date.strftime("%d-%m-%Y")
+ self.release_date_label.setText(f"Released {formatted_date}")
+ 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.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.status_label.setText("Preparing...")
- format_type = 'artist_title' if self.format_artist_title.isChecked() else 'title_artist'
- fallback = self.fallback_checkbox.isChecked()
+ 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.", [])
- self.worker = DownloaderWorker(
- metadata=self.metadata,
- output_dir=output_dir,
- filename_format=format_type,
- use_fallback=fallback
- )
-
- self.worker.progress.connect(self.update_progress)
- self.worker.status.connect(self.update_status)
- self.worker.finished.connect(self.download_finished)
- self.worker.error.connect(self.download_error)
- self.worker.start()
-
- def update_status(self, status):
- self.status_label.setText(status)
-
- def update_progress(self, value):
- self.progress_bar.setValue(value)
-
- def download_finished(self, message):
+ def on_download_finished(self, success, message, failed_tracks):
self.progress_bar.hide()
- self.status_label.setText(message)
- self.open_button.show()
- self.download_button.setText("Clear")
- self.download_button.show()
- self.cancel_button.hide()
- self.download_button.setEnabled(True)
+ 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}")
- def download_error(self, error_message):
- self.progress_bar.hide()
- self.status_label.setText(error_message)
- self.download_button.setText("Retry")
- self.download_button.show()
- self.cancel_button.show()
- self.download_button.setEnabled(True)
- self.cancel_button.setEnabled(True)
+ 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 main():
+ 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)
- window = SpotiFlacGUI()
- window.show()
- sys.exit(app.exec())
-
-if __name__ == "__main__":
- main()
+ ex = SpotiFLACGUI()
+ ex.show()
+ sys.exit(app.exec())
\ No newline at end of file
diff --git a/getMetadata.py b/getMetadata.py
new file mode 100644
index 0000000..4d8ccf3
--- /dev/null
+++ b/getMetadata.py
@@ -0,0 +1,319 @@
+from time import sleep
+from urllib.parse import urlparse, parse_qs
+import requests
+import json
+import hmac
+import time
+import hashlib
+from typing import Tuple, Callable
+
+_TOTP_SECRET = bytearray([53,53,48,55,49,52,53,56,53,51,52,56,55,52,57,57,53,57,50,50,52,56,54,51,48,51,50,57,51,52,55])
+
+def generate_totp(
+ secret: bytes = _TOTP_SECRET,
+ algorithm: Callable[[], object] = hashlib.sha1,
+ digits: int = 6,
+ counter_factory: Callable[[], int] = lambda: int(time.time()) // 30,
+) -> Tuple[str, int]:
+ counter = counter_factory()
+ hmac_result = hmac.new(
+ secret, counter.to_bytes(8, byteorder="big"), algorithm
+ ).digest()
+
+ offset = hmac_result[-1] & 15
+ truncated_value = (
+ (hmac_result[offset] & 127) << 24
+ | (hmac_result[offset + 1] & 255) << 16
+ | (hmac_result[offset + 2] & 255) << 8
+ | (hmac_result[offset + 3] & 255)
+ )
+ return (
+ str(truncated_value % (10**digits)).zfill(digits),
+ counter * 30_000,
+ )
+
+token_url = 'https://open.spotify.com/get_access_token'
+playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
+album_base_url = 'https://api.spotify.com/v1/albums/{}'
+track_base_url = 'https://api.spotify.com/v1/tracks/{}'
+headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
+ 'Accept': 'application/json',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ 'sec-ch-ua-platform': '"Windows"',
+ 'sec-fetch-dest': 'empty',
+ 'sec-fetch-mode': 'cors',
+ 'sec-fetch-site': 'same-origin',
+ 'Referer': 'https://open.spotify.com/',
+ 'Origin': 'https://open.spotify.com'
+}
+
+class SpotifyInvalidUrlException(Exception):
+ pass
+
+class SpotifyWebsiteParserException(Exception):
+ pass
+
+def parse_uri(uri):
+ u = urlparse(uri)
+ if u.netloc == "embed.spotify.com":
+ if not u.query:
+ raise SpotifyInvalidUrlException("ERROR: url {} is not supported".format(uri))
+ qs = parse_qs(u.query)
+ return parse_uri(qs['uri'][0])
+
+ if not u.scheme and not u.netloc:
+ return {"type": "playlist", "id": u.path}
+
+ if u.scheme == "spotify":
+ parts = uri.split(":")
+ else:
+ if u.netloc != "open.spotify.com" and u.netloc != "play.spotify.com":
+ raise SpotifyInvalidUrlException("ERROR: url {} is not supported".format(uri))
+ parts = u.path.split("/")
+
+ if parts[1] == "embed":
+ parts = parts[1:]
+
+ l = len(parts)
+ if l == 3 and parts[1] in ["album", "track", "playlist"]:
+ return {"type": parts[1], "id": parts[2]}
+ if l == 5 and parts[3] == "playlist":
+ return {"type": parts[3], "id": parts[4]}
+
+ raise SpotifyInvalidUrlException("ERROR: unable to determine Spotify URL type or type is unsupported.")
+
+def get_json_from_api(api_url, access_token):
+ headers.update({'Authorization': 'Bearer {}'.format(access_token)})
+
+ req = requests.get(api_url, headers=headers, timeout=10)
+
+ if req.status_code == 429:
+ seconds = int(req.headers.get("Retry-After", "5")) + 1
+ print(f"INFO: rate limited! Sleeping for {seconds} seconds")
+ sleep(seconds)
+ return None
+
+ if req.status_code != 200:
+ raise SpotifyWebsiteParserException(f"ERROR: {api_url} gave us not a 200. Instead: {req.status_code}")
+
+ return req.json()
+
+def get_raw_spotify_data(spotify_url):
+ url_info = parse_uri(spotify_url)
+
+ try:
+ totp, timestamp = generate_totp()
+
+ params = {
+ "reason": "init",
+ "productType": "web-player",
+ "totp": totp,
+ "totpVer": 5,
+ "ts": timestamp,
+ }
+
+ req = requests.get(token_url, headers=headers, params=params, timeout=10)
+ if req.status_code != 200:
+ return {"error": f"Failed to get access token. Status code: {req.status_code}"}
+ token = req.json()
+ except Exception as e:
+ return {"error": f"Failed to get access token: {str(e)}"}
+
+ raw_data = {}
+
+ if url_info['type'] == "playlist":
+ try:
+ playlist_data = get_json_from_api(
+ playlist_base_url.format(url_info["id"]),
+ token["accessToken"]
+ )
+ if not playlist_data:
+ return {"error": "Failed to get playlist data"}
+
+ raw_data = playlist_data
+
+ tracks = []
+ tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
+ while tracks_url:
+ track_data = get_json_from_api(tracks_url, token["accessToken"])
+ if not track_data:
+ break
+
+ tracks.extend(track_data['items'])
+ tracks_url = track_data.get('next')
+
+ raw_data['tracks']['items'] = tracks
+ except Exception as e:
+ return {"error": f"Failed to get playlist data: {str(e)}"}
+
+ elif url_info["type"] == "album":
+ try:
+ album_data = get_json_from_api(
+ album_base_url.format(url_info["id"]),
+ token["accessToken"]
+ )
+ if not album_data:
+ return {"error": "Failed to get album data"}
+
+ album_data['_token'] = token["accessToken"]
+ raw_data = album_data
+
+ tracks = []
+ tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
+ while tracks_url:
+ track_data = get_json_from_api(tracks_url, token["accessToken"])
+ if not track_data:
+ break
+
+ tracks.extend(track_data['items'])
+ tracks_url = track_data.get('next')
+
+ raw_data['tracks']['items'] = tracks
+ except Exception as e:
+ return {"error": f"Failed to get album data: {str(e)}"}
+
+ elif url_info["type"] == "track":
+ try:
+ track_data = get_json_from_api(
+ track_base_url.format(url_info["id"]),
+ token["accessToken"]
+ )
+ if not track_data:
+ return {"error": "Failed to get track data"}
+
+ raw_data = track_data
+ except Exception as e:
+ return {"error": f"Failed to get track data: {str(e)}"}
+
+ return raw_data
+
+def format_track_data(track_data):
+ artists = []
+ for artist in track_data['artists']:
+ artists.append(artist['name'])
+
+ image_url = track_data.get('album', {}).get('images', [{}])[0].get('url', '')
+
+ return {
+ "track": {
+ "artists": ", ".join(artists),
+ "name": track_data.get('name', ''),
+ "album_name": track_data.get('album', {}).get('name', ''),
+ "duration_ms": track_data.get('duration_ms', 0),
+ "images": image_url,
+ "release_date": track_data.get('album', {}).get('release_date', ''),
+ "track_number": track_data.get('track_number', 0),
+ "external_urls": track_data.get('external_urls', {}).get('spotify', '')
+ }
+ }
+
+def format_album_data(album_data):
+ artists = []
+ for artist in album_data['artists']:
+ artists.append(artist['name'])
+
+ image_url = album_data.get('images', [{}])[0].get('url', '')
+
+ track_list = []
+ for track in album_data.get('tracks', {}).get('items', []):
+ track_artists = []
+ for artist in track.get('artists', []):
+ track_artists.append(artist['name'])
+
+ track_list.append({
+ "artists": ", ".join(track_artists),
+ "name": track.get('name', ''),
+ "album_name": album_data.get('name', ''),
+ "duration_ms": track.get('duration_ms', 0),
+ "images": image_url,
+ "release_date": album_data.get('release_date', ''),
+ "track_number": track.get('track_number', 0),
+ "external_urls": track.get('external_urls', {}).get('spotify', '')
+ })
+
+ return {
+ "album_info": {
+ "total_tracks": album_data.get('total_tracks', 0),
+ "name": album_data.get('name', ''),
+ "release_date": album_data.get('release_date', ''),
+ "artists": ", ".join(artists),
+ "images": image_url
+ },
+ "track_list": track_list
+ }
+
+def format_playlist_data(playlist_data):
+ image_url = playlist_data.get('images', [{}])[0].get('url', '')
+
+ track_list = []
+ for item in playlist_data.get('tracks', {}).get('items', []):
+ track = item.get('track', {})
+ artists = []
+ for artist in track.get('artists', []):
+ artists.append(artist['name'])
+
+ track_image = track.get('album', {}).get('images', [{}])[0].get('url', '')
+
+ track_list.append({
+ "artists": ", ".join(artists),
+ "name": track.get('name', ''),
+ "album_name": track.get('album', {}).get('name', ''),
+ "duration_ms": track.get('duration_ms', 0),
+ "images": track_image,
+ "release_date": track.get('album', {}).get('release_date', ''),
+ "track_number": track.get('track_number', 0),
+ "external_urls": track.get('external_urls', {}).get('spotify', '')
+ })
+
+ return {
+ "playlist_info": {
+ "tracks": {"total": playlist_data.get('tracks', {}).get('total', 0)},
+ "followers": {"total": playlist_data.get('followers', {}).get('total', 0)},
+ "owner": {
+ "display_name": playlist_data.get('owner', {}).get('display_name', ''),
+ "name": playlist_data.get('name', ''),
+ "images": image_url
+ }
+ },
+ "track_list": track_list
+ }
+
+def process_spotify_data(raw_data, data_type):
+ if not raw_data or "error" in raw_data:
+ return {"error": "Invalid data provided"}
+
+ try:
+ if data_type == "track":
+ return format_track_data(raw_data)
+ elif data_type == "album":
+ return format_album_data(raw_data)
+ elif data_type == "playlist":
+ return format_playlist_data(raw_data)
+ else:
+ return {"error": "Invalid data type"}
+ except Exception as e:
+ return {"error": f"Error processing data: {str(e)}"}
+
+def get_filtered_data(spotify_url):
+ raw_data = get_raw_spotify_data(spotify_url)
+ if raw_data and "error" not in raw_data:
+ url_info = parse_uri(spotify_url)
+ filtered_data = process_spotify_data(raw_data, url_info['type'])
+ return filtered_data
+ return {"error": "Failed to get raw data"}
+
+if __name__ == '__main__':
+ playlist = "https://open.spotify.com/playlist/37i9dQZEVXbNG2KDcFcKOF"
+ album = "https://open.spotify.com/album/7kFyd5oyJdVX2pIi6P4iHE"
+ song = "https://open.spotify.com/track/4wJ5Qq0jBN4ajy7ouZIV1c"
+
+ filtered_playlist = get_filtered_data(playlist)
+ print(json.dumps(filtered_playlist, indent=2))
+
+ filtered_album = get_filtered_data(album)
+ print(json.dumps(filtered_album, indent=2))
+
+ filtered_track = get_filtered_data(song)
+ print(json.dumps(filtered_track, indent=2))
\ No newline at end of file
diff --git a/getTracks.py b/getTracks.py
index 3749ff3..eab8f61 100644
--- a/getTracks.py
+++ b/getTracks.py
@@ -2,6 +2,8 @@ import requests
import time
import os
import asyncio
+import re
+import base64
class TrackDownloader:
def __init__(self, use_fallback=False):
@@ -13,7 +15,6 @@ class TrackDownloader:
self.filename_format = 'title_artist'
self.use_fallback = use_fallback
self.base_domain = "lucida.su" if use_fallback else "lucida.to"
- self.api_base = "https://apislucida.vercel.app"
def set_progress_callback(self, callback):
self.progress_callback = callback
@@ -32,15 +33,121 @@ class TrackDownloader:
if use_fallback is None:
use_fallback = self.use_fallback
- fallback = "su" if use_fallback else "to"
- api_url = f"{self.api_base}/{fallback}/{track_id}/{service}"
+ domain_type = "su" if use_fallback else "to"
+
+ spotify_url = f"https://open.spotify.com/track/{track_id}"
+
+ result = self.convert_spotify_link(spotify_url, service, domain_type)
+
+ if "error" in result:
+ raise Exception(f"Failed to get track info: {result['error']}")
+
+ return result
+
+ def convert_spotify_link(self, spotify_url, target_service="amazon", domain_type="to"):
+ track_id_match = re.search(r'track/([a-zA-Z0-9]+)', spotify_url)
+ if not track_id_match:
+ return {"error": "Invalid Spotify URL"}
+
+ domain = "lucida.to" if domain_type == "to" else "lucida.su"
+ base_url = f"https://{domain}"
+
+ headers = {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
+ "Accept-Language": "id-ID,id;q=0.9",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "Host": domain,
+ "Pragma": "no-cache",
+ "Upgrade-Insecure-Requests": "1",
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
+ }
try:
- response = requests.get(api_url)
- response.raise_for_status()
- return response.json()
- except requests.exceptions.RequestException as e:
- raise Exception(f"Failed to get track info: {str(e)}")
+ headers["Referer"] = f"{base_url}/?url={spotify_url}&country=auto"
+
+ request_params = {
+ "url": spotify_url,
+ "country": "auto",
+ "to": target_service
+ }
+
+ session = requests.Session()
+ session.verify = False
+
+ response = session.get(
+ base_url,
+ params=request_params,
+ headers=headers,
+ timeout=30
+ )
+
+ html_content = response.text
+
+ token_match = re.search(r'token:"([^"]+)"', html_content)
+ token_expiry_match = re.search(r'tokenExpiry:(\d+)', html_content)
+
+ token = token_match.group(1) if token_match else None
+ token_expiry = int(token_expiry_match.group(1)) if token_expiry_match else None
+
+ url = None
+ url_patterns = [
+ r'"url":"([^"]+)"',
+ r'href="(https?://[^"]*' + re.escape(target_service) + r'[^"]*track[^"]*)"',
+ ]
+
+ for pattern in url_patterns:
+ url_match = re.search(pattern, html_content)
+ if url_match:
+ url = url_match.group(1).replace('\\/', '/')
+ break
+
+ if not url:
+ redirect_patterns = [
+ r'url=([^&"]+)',
+ r'href="([^"]+)"',
+ r'window\.location\.href\s*=\s*[\'"]([^\'"]+)[\'"]',
+ ]
+
+ for pattern in redirect_patterns:
+ matches = re.finditer(pattern, html_content)
+ for match in matches:
+ potential_url = match.group(1)
+ if potential_url.startswith('http') and target_service.lower() in potential_url.lower():
+ url = potential_url.replace('\\/', '/')
+ break
+
+ if not url:
+ service_urls = re.finditer(r'(https?://[^"\s]+' + re.escape(target_service) + r'[^"\s]+)', html_content)
+ for match in service_urls:
+ url = match.group(1).replace('\\/', '/')
+ break
+
+ result = {
+ "service": target_service,
+ "url": url,
+ "token": {
+ "primary": None,
+ "expiry": None
+ },
+ "title": "Title",
+ "artists": "Artist"
+ }
+
+ if token:
+ try:
+ decoded_once = base64.b64decode(token).decode('latin1')
+ decoded_token = base64.b64decode(decoded_once).decode('latin1')
+ result["token"]["primary"] = decoded_token
+ except Exception:
+ result["token"]["primary"] = token
+
+ result["token"]["expiry"] = token_expiry
+
+ return result
+
+ except Exception as error:
+ return {"error": str(error)}
def sanitize_filename(self, filename):
invalid_chars = '<>:"/\\|?*'
@@ -177,7 +284,9 @@ class TrackDownloader:
raise e
async def main():
- downloader = TrackDownloader()
+ use_fallback = False
+ downloader = TrackDownloader(use_fallback)
+
output_dir = "."
track_id = "2plbrEY59IikOBgBGLjaoe"
service = "amazon"
@@ -192,7 +301,7 @@ async def main():
try:
print(f"Getting track info for ID: {track_id} from {service}")
metadata = await downloader.get_track_info(track_id, service)
- print(f"Track info received: {metadata['title']} by {metadata['artists']}")
+ print(f"Track info received, starting download process")
downloaded_file = downloader.download(metadata, output_dir)
print(f"\nFile downloaded successfully: {downloaded_file}")
@@ -200,4 +309,4 @@ async def main():
print(f"An error occurred: {str(e)}")
if __name__ == "__main__":
- asyncio.run(main())
+ asyncio.run(main())
\ No newline at end of file