Compare commits

...

10 Commits

Author SHA1 Message Date
afkarxyz 1e7a48d263 v2.6 2025-05-06 10:38:01 +07:00
afkarxyz 0a83a0dd6e Update README.md 2025-05-06 10:35:17 +07:00
afkarxyz da429d9410 v2.5 2025-04-25 06:33:32 +07:00
afkarxyz 63211c726b v2.5 2025-04-25 06:30:14 +07:00
afkarxyz 055cb6991a Update v2.4 2025-04-08 13:10:12 +07:00
afkarxyz 222d681551 Update v2.4 2025-04-08 13:07:26 +07:00
afkarxyz 479c6ede2b Update v2.3 2025-03-20 05:47:20 +07:00
afkarxyz ceb727adb9 Update v2.3 2025-03-20 05:41:28 +07:00
afkarxyz bbea8ca493 Update README.md 2025-03-20 05:36:38 +07:00
afkarxyz f567dd19bf Update v2.2 2025-03-18 08:01:11 +07:00
5 changed files with 368 additions and 130 deletions
+10 -6
View File
@@ -1,12 +1,12 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
![spotiflac](https://github.com/user-attachments/assets/a233a276-14a4-4f4c-b267-f182dd3912a0)
![SpotiFLAC](https://github.com/user-attachments/assets/b4c4f403-edbd-4a71-b74b-c7d433d47d06)
<div align="center">
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal, Amazon Music and Deezer with the help of Lucida.
</div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v2.2/SpotiFLAC.exe)
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v2.5/SpotiFLAC.exe)
#
@@ -15,13 +15,17 @@ Sometimes, the **download speed** from Lucida can be fast or slow; it varies unp
## Screenshots
![image](https://github.com/user-attachments/assets/7fa82a25-0fe8-4b87-ba5c-2dd5933f211b)
![image](https://github.com/user-attachments/assets/70a5dceb-3374-4255-8f6a-4afb5ee534b0)
![image](https://github.com/user-attachments/assets/81e65977-11f0-4162-96f3-90730dd87e74)
![image](https://github.com/user-attachments/assets/9f0d6aa5-456b-4a90-b48a-7e0c22819ebd)
![image](https://github.com/user-attachments/assets/4dd37c0a-30e3-479a-9b3d-57fd360d87b3)
![image](https://github.com/user-attachments/assets/be9a79b5-260e-4948-9f72-10c735210ab7)
![image](https://github.com/user-attachments/assets/04954db9-e94a-4f9d-8eac-46d7ff7a4c33)
![image](https://github.com/user-attachments/assets/c4403934-9003-447e-a27b-fc74cab23454)
![image](https://github.com/user-attachments/assets/1feec621-f8bf-4b2a-ae73-afcb1fb1deba)
![image](https://github.com/user-attachments/assets/66cc3398-547d-4568-8d49-a05ad4997370)
> When **Fallback** is enabled, it will use the backup server `Lucida.su`
+121 -31
View File
@@ -29,13 +29,33 @@ class Track:
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"):
use_album_subfolders=False, use_fallback=False, service="amazon", timeout=30):
super().__init__()
self.tracks = tracks
self.outpath = outpath
@@ -48,6 +68,7 @@ class DownloadWorker(QThread):
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 = []
@@ -61,18 +82,19 @@ class DownloadWorker(QThread):
def run(self):
try:
downloader = TrackDownloader(self.use_fallback)
downloader = TrackDownloader(self.use_fallback, self.timeout)
def progress_update(current, total):
if total > 0:
percent = (current / total) * 100
self.progress.emit(f"Download progress: {percent:.2f}% ({current}/{total})",
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)
downloader.set_filename_format(self.filename_format)
total_tracks = len(self.tracks)
@@ -102,7 +124,16 @@ class DownloadWorker(QThread):
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)
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)}"
@@ -300,7 +331,7 @@ class ServiceComboBox(QComboBox):
class SpotiFLACGUI(QWidget):
def __init__(self):
super().__init__()
self.current_version = "2.2"
self.current_version = "2.6"
self.tracks = []
self.reset_state()
@@ -313,6 +344,7 @@ class SpotiFLACGUI(QWidget):
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)
@@ -556,7 +588,7 @@ class SpotiFLACGUI(QWidget):
output_layout.setSpacing(5)
output_label = QLabel('Output Directory')
output_label.setStyleSheet("font-weight: bold; color: palette(text);")
output_label.setStyleSheet("font-weight: bold;")
output_layout.addWidget(output_label)
output_dir_layout = QHBoxLayout()
@@ -579,21 +611,18 @@ class SpotiFLACGUI(QWidget):
file_layout.setSpacing(5)
file_label = QLabel('File Settings')
file_label.setStyleSheet("font-weight: bold; color: palette(text);")
file_label.setStyleSheet("font-weight: bold;")
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)
@@ -614,14 +643,12 @@ class SpotiFLACGUI(QWidget):
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)
@@ -636,14 +663,13 @@ class SpotiFLACGUI(QWidget):
auth_layout = QVBoxLayout(auth_group)
auth_layout.setSpacing(5)
auth_label = QLabel('Lucida')
auth_label.setStyleSheet("font-weight: bold; color: palette(text);")
auth_label = QLabel('Lucida Settings')
auth_label.setStyleSheet("font-weight: bold;")
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)
@@ -654,12 +680,21 @@ class SpotiFLACGUI(QWidget):
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.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)
@@ -719,8 +754,8 @@ class SpotiFLACGUI(QWidget):
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;")
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)
@@ -751,6 +786,21 @@ class SpotiFLACGUI(QWidget):
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
@@ -778,11 +828,20 @@ class SpotiFLACGUI(QWidget):
self.reset_state()
self.reset_ui()
metadata = get_filtered_data(url)
if "error" in metadata:
raise Exception(metadata["error"])
self.log_output.append('Just a moment. Fetching metadata...')
self.tab_widget.setCurrentWidget(self.process_tab)
url_info = parse_uri(url)
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"])
@@ -793,11 +852,11 @@ class SpotiFLACGUI(QWidget):
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)}')
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]
@@ -911,10 +970,21 @@ class SpotiFLACGUI(QWidget):
self.followers_label.hide()
if metadata.get('releaseDate'):
release_date = datetime.strptime(metadata['releaseDate'], "%Y-%m-%d")
formatted_date = release_date.strftime("%d-%m-%Y")
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"<b>Released</b> {formatted_date}")
self.release_date_label.show()
except ValueError:
self.release_date_label.setText(f"<b>Released</b> {metadata['releaseDate']}")
self.release_date_label.show()
else:
self.release_date_label.hide()
@@ -1025,7 +1095,8 @@ class SpotiFLACGUI(QWidget):
self.use_track_numbers,
self.use_album_subfolders,
self.use_fallback,
service
service,
self.timeout_value
)
self.worker.finished.connect(self.on_download_finished)
self.worker.progress.connect(self.update_progress)
@@ -1044,8 +1115,27 @@ class SpotiFLACGUI(QWidget):
self.tab_widget.setCurrentWidget(self.process_tab)
def update_progress(self, message, percentage):
self.log_output.append(message)
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)
+164 -26
View File
@@ -5,7 +5,7 @@ import json
import hmac
import time
import hashlib
from typing import Tuple, Callable
from typing import Tuple, Callable, Dict, Any, List
_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])
@@ -100,9 +100,7 @@ def get_json_from_api(api_url, access_token):
return req.json()
def get_raw_spotify_data(spotify_url):
url_info = parse_uri(spotify_url)
def get_access_token():
try:
totp, timestamp = generate_totp()
@@ -117,34 +115,116 @@ def get_raw_spotify_data(spotify_url):
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()
return req.json()
except Exception as e:
return {"error": f"Failed to get access token: {str(e)}"}
def fetch_tracks_in_batches(url: str, access_token: str, batch_size: int = 100, delay: float = 1.0) -> Tuple[List[Dict[str, Any]], int]:
all_tracks = []
current_batch = 0
while url:
print(f"Batch : {current_batch}")
url_parts = url.split("offset=")
if len(url_parts) > 1:
offset_part = url_parts[1].split("&")[0]
print(f"Offset : {offset_part}")
print("-------------")
track_data = get_json_from_api(url, access_token)
if not track_data:
break
items = track_data.get('items', [])
all_tracks.extend(items)
url = track_data.get('next')
if url and "&locale=" in url:
url = url.split("&locale=")[0]
if url and delay > 0:
sleep(delay)
current_batch += 1
return all_tracks, current_batch
def get_raw_spotify_data(spotify_url, batch: bool = False, delay: float = 1.0):
url_info = parse_uri(spotify_url)
token = get_access_token()
if "error" in token:
return token
access_token = token["accessToken"]
raw_data = {}
if url_info['type'] == "playlist":
try:
playlist_data = get_json_from_api(
playlist_base_url.format(url_info["id"]),
token["accessToken"]
access_token
)
if not playlist_data:
return {"error": "Failed to get playlist data"}
raw_data = playlist_data
total_tracks = playlist_data.get('tracks', {}).get('total', 0)
if batch:
tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
tracks, num_batches = fetch_tracks_in_batches(tracks_url, access_token, 100, delay)
raw_data['tracks']['items'] = tracks
raw_data['_batch_count'] = num_batches
raw_data['_batch_enabled'] = True
if len(tracks) < total_tracks:
last_offset = len(tracks)
remaining_tracks = []
while last_offset < total_tracks:
print(f"Batch : {num_batches}")
print(f"Offset : {last_offset}")
print("-------------")
remainder_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?offset={last_offset}&limit=100'
track_data = get_json_from_api(remainder_url, access_token)
if not track_data or not track_data.get('items'):
break
items = track_data.get('items', [])
remaining_tracks.extend(items)
if len(items) < 100:
break
last_offset += len(items)
num_batches += 1
if delay > 0:
sleep(delay)
tracks.extend(remaining_tracks)
raw_data['tracks']['items'] = tracks
raw_data['_batch_count'] = num_batches
else:
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"])
track_data = get_json_from_api(tracks_url, access_token)
if not track_data:
break
tracks.extend(track_data['items'])
tracks_url = track_data.get('next')
if tracks_url and "&locale=" in tracks_url:
tracks_url = tracks_url.split("&locale=")[0]
raw_data['tracks']['items'] = tracks
raw_data['_batch_enabled'] = False
except Exception as e:
return {"error": f"Failed to get playlist data: {str(e)}"}
@@ -152,25 +232,68 @@ def get_raw_spotify_data(spotify_url):
try:
album_data = get_json_from_api(
album_base_url.format(url_info["id"]),
token["accessToken"]
access_token
)
if not album_data:
return {"error": "Failed to get album data"}
album_data['_token'] = token["accessToken"]
album_data['_token'] = access_token
raw_data = album_data
total_tracks = album_data.get('total_tracks', 0)
if batch:
tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
tracks, num_batches = fetch_tracks_in_batches(tracks_url, access_token, 50, delay)
raw_data['tracks']['items'] = tracks
raw_data['_batch_count'] = num_batches
raw_data['_batch_enabled'] = True
if len(tracks) < total_tracks:
last_offset = len(tracks)
remaining_tracks = []
while last_offset < total_tracks:
print(f"Batch : {num_batches}")
print(f"Offset : {last_offset}")
print("-------------")
remainder_url = f'{album_base_url.format(url_info["id"])}/tracks?offset={last_offset}&limit=50'
track_data = get_json_from_api(remainder_url, access_token)
if not track_data or not track_data.get('items'):
break
items = track_data.get('items', [])
remaining_tracks.extend(items)
if len(items) < 50:
break
last_offset += len(items)
num_batches += 1
if delay > 0:
sleep(delay)
tracks.extend(remaining_tracks)
raw_data['tracks']['items'] = tracks
raw_data['_batch_count'] = num_batches
else:
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"])
track_data = get_json_from_api(tracks_url, access_token)
if not track_data:
break
tracks.extend(track_data['items'])
tracks_url = track_data.get('next')
if tracks_url and "&locale=" in tracks_url:
tracks_url = tracks_url.split("&locale=")[0]
raw_data['tracks']['items'] = tracks
raw_data['_batch_enabled'] = False
except Exception as e:
return {"error": f"Failed to get album data: {str(e)}"}
@@ -178,7 +301,7 @@ def get_raw_spotify_data(spotify_url):
try:
track_data = get_json_from_api(
track_base_url.format(url_info["id"]),
token["accessToken"]
access_token
)
if not track_data:
return {"error": "Failed to get track data"}
@@ -191,10 +314,10 @@ def get_raw_spotify_data(spotify_url):
def format_track_data(track_data):
artists = []
for artist in track_data['artists']:
for artist in track_data.get('artists', []):
artists.append(artist['name'])
image_url = track_data.get('album', {}).get('images', [{}])[0].get('url', '')
image_url = track_data.get('album', {}).get('images', [{}])[0].get('url', '') if track_data.get('album', {}).get('images') else ''
return {
"track": {
@@ -211,10 +334,10 @@ def format_track_data(track_data):
def format_album_data(album_data):
artists = []
for artist in album_data['artists']:
for artist in album_data.get('artists', []):
artists.append(artist['name'])
image_url = album_data.get('images', [{}])[0].get('url', '')
image_url = album_data.get('images', [{}])[0].get('url', '') if album_data.get('images') else ''
track_list = []
for track in album_data.get('tracks', {}).get('items', []):
@@ -233,27 +356,37 @@ def format_album_data(album_data):
"external_urls": track.get('external_urls', {}).get('spotify', '')
})
return {
"album_info": {
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
},
}
if album_data.get('_batch_enabled', False):
album_info["batch"] = f"{album_data.get('_batch_count', 1)}"
return {
"album_info": album_info,
"track_list": track_list
}
def format_playlist_data(playlist_data):
image_url = playlist_data.get('images', [{}])[0].get('url', '')
image_url = playlist_data.get('images', [{}])[0].get('url', '') if playlist_data.get('images') else ''
track_list = []
for item in playlist_data.get('tracks', {}).get('items', []):
track = item.get('track', {})
if not track:
continue
artists = []
for artist in track.get('artists', []):
artists.append(artist['name'])
track_image = ''
if track.get('album', {}).get('images'):
track_image = track.get('album', {}).get('images', [{}])[0].get('url', '')
track_list.append({
@@ -267,8 +400,7 @@ def format_playlist_data(playlist_data):
"external_urls": track.get('external_urls', {}).get('spotify', '')
})
return {
"playlist_info": {
playlist_info = {
"tracks": {"total": playlist_data.get('tracks', {}).get('total', 0)},
"followers": {"total": playlist_data.get('followers', {}).get('total', 0)},
"owner": {
@@ -276,7 +408,13 @@ def format_playlist_data(playlist_data):
"name": playlist_data.get('name', ''),
"images": image_url
}
},
}
if playlist_data.get('_batch_enabled', False):
playlist_info["batch"] = f"{playlist_data.get('_batch_count', 1)}"
return {
"playlist_info": playlist_info,
"track_list": track_list
}
@@ -296,8 +434,8 @@ def process_spotify_data(raw_data, 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)
def get_filtered_data(spotify_url, batch=False, delay=1.0):
raw_data = get_raw_spotify_data(spotify_url, batch=batch, delay=delay)
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'])
@@ -305,11 +443,11 @@ def get_filtered_data(spotify_url):
return {"error": "Failed to get raw data"}
if __name__ == '__main__':
playlist = "https://open.spotify.com/playlist/37i9dQZEVXbNG2KDcFcKOF"
playlist = "https://open.spotify.com/playlist/5Qvz8wZIRYbEUUFoPueKI5"
album = "https://open.spotify.com/album/7kFyd5oyJdVX2pIi6P4iHE"
song = "https://open.spotify.com/track/4wJ5Qq0jBN4ajy7ouZIV1c"
filtered_playlist = get_filtered_data(playlist)
filtered_playlist = get_filtered_data(playlist, batch=True, delay=0.1)
print(json.dumps(filtered_playlist, indent=2))
filtered_album = get_filtered_data(album)
+39 -33
View File
@@ -6,28 +6,21 @@ import re
import base64
class TrackDownloader:
def __init__(self, use_fallback=False):
def __init__(self, use_fallback=False, timeout=30):
self.client = requests.Session()
self.headers = {
'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'
}
self.progress_callback = None
self.filename_format = 'title_artist'
self.use_fallback = use_fallback
self.timeout = timeout
self.base_domain = "lucida.su" if use_fallback else "lucida.to"
def set_progress_callback(self, callback):
self.progress_callback = callback
def set_filename_format(self, format_type):
self.filename_format = format_type
def generate_filename(self, metadata):
if self.filename_format == 'artist_title':
filename = f"{metadata['artists']} - {metadata['title']}.flac"
else:
filename = f"{metadata['title']} - {metadata['artists']}.flac"
return self.sanitize_filename(filename)
def generate_filename(self, track_id, service):
return f"{track_id}_{service}.flac"
async def get_track_info(self, track_id, service="amazon", use_fallback=None):
if use_fallback is None:
@@ -42,6 +35,8 @@ class TrackDownloader:
if "error" in result:
raise Exception(f"Failed to get track info: {result['error']}")
result["track_id"] = track_id
return result
def convert_spotify_link(self, spotify_url, target_service="amazon", domain_type="to"):
@@ -73,13 +68,13 @@ class TrackDownloader:
}
session = requests.Session()
session.verify = False
session.verify = True
response = session.get(
base_url,
params=request_params,
headers=headers,
timeout=30
timeout=self.timeout
)
html_content = response.text
@@ -129,9 +124,7 @@ class TrackDownloader:
"token": {
"primary": None,
"expiry": None
},
"title": "Title",
"artists": "Artist"
}
}
if token:
@@ -149,27 +142,18 @@ class TrackDownloader:
except Exception as error:
return {"error": str(error)}
def sanitize_filename(self, filename):
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
filename = filename.replace(char, '')
filename = ' '.join(filename.split())
filename = filename.replace(' ,', ',')
filename = filename.replace(',', ', ')
while ' ' in filename:
filename = filename.replace(' ', ' ')
filename = filename.rsplit('.', 1)
filename[0] = filename[0].strip()
return '.'.join(filename)
def download(self, metadata, output_dir):
def download(self, metadata, output_dir, is_paused_callback=None, is_stopped_callback=None):
track_url = metadata['url']
primary_token = metadata['token']['primary']
expiry = metadata['token']['expiry']
track_id = metadata['track_id']
service = metadata['service']
print(f"Starting download for: {track_url}")
if is_stopped_callback and is_stopped_callback():
raise Exception("Download stopped by user")
initial_request = {
"account": {"id": "auto", "type": "country"},
"compat": "false",
@@ -201,12 +185,20 @@ class TrackDownloader:
handoff = initial_response["handoff"]
server = initial_response["server"]
file_name = self.generate_filename(metadata)
file_name = self.generate_filename(track_id, service)
completion_url = f"https://{server}.{self.base_domain}/api/fetch/request/{handoff}"
print("Waiting for track processing to complete")
while True:
if is_stopped_callback and is_stopped_callback():
raise Exception("Download stopped by user")
while is_paused_callback and is_paused_callback():
time.sleep(0.1)
if is_stopped_callback and is_stopped_callback():
raise Exception("Download stopped by user")
completion_response = self.client.get(completion_url, headers=self.headers).json()
status = completion_response["status"]
@@ -250,6 +242,20 @@ class TrackDownloader:
last_update_time = start_time
for chunk in response.iter_content(chunk_size=8192):
if is_stopped_callback and is_stopped_callback():
file.close()
if os.path.exists(file_path):
os.remove(file_path)
raise Exception("Download stopped by user")
while is_paused_callback and is_paused_callback():
time.sleep(0.1)
if is_stopped_callback and is_stopped_callback():
file.close()
if os.path.exists(file_path):
os.remove(file_path)
raise Exception("Download stopped by user")
if chunk:
file.write(chunk)
downloaded_size += len(chunk)
@@ -289,7 +295,7 @@ async def main():
output_dir = "."
track_id = "2plbrEY59IikOBgBGLjaoe"
service = "amazon"
service = "tidal"
def progress_update(current, total):
if total > 0:
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "2.1"
"version": "2.5"
}