v4.2
This commit is contained in:
+333
-114
@@ -2,15 +2,17 @@ import sys
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import requests
|
||||
import re
|
||||
import asyncio
|
||||
from packaging import version
|
||||
import qdarktheme
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit,
|
||||
QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton,
|
||||
QAbstractItemView, QSpacerItem, QSizePolicy, QProgressBar, QCheckBox, QDialog,
|
||||
QAbstractItemView, QProgressBar, QCheckBox, QDialog,
|
||||
QDialogButtonBox, QComboBox, QStyledItemDelegate
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer, QTime, QSettings, QSize
|
||||
@@ -82,7 +84,7 @@ class DownloadWorker(QThread):
|
||||
filename = f"{track.title}.flac"
|
||||
else:
|
||||
filename = f"{track.title} - {track.artists}.flac"
|
||||
return re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
return re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', filename)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
@@ -94,16 +96,12 @@ class DownloadWorker(QThread):
|
||||
downloader = DeezerDownloader()
|
||||
else:
|
||||
downloader = TidalDownloader()
|
||||
|
||||
def progress_update(current, total):
|
||||
if total > 0:
|
||||
percent = (current / total) * 100
|
||||
current_mb = current / (1024 * 1024)
|
||||
total_mb = total / (1024 * 1024)
|
||||
self.progress.emit(f"Download progress: {percent:.2f}% ({current_mb:.2f}MB/{total_mb:.2f}MB)",
|
||||
int(percent))
|
||||
self.progress.emit("", int(percent))
|
||||
else:
|
||||
self.progress.emit(f"Processing metadata...", 0)
|
||||
self.progress.emit("Processing metadata...", 0)
|
||||
|
||||
downloader.set_progress_callback(progress_update)
|
||||
|
||||
@@ -133,7 +131,7 @@ class DownloadWorker(QThread):
|
||||
else:
|
||||
new_filename = self.get_formatted_filename(track)
|
||||
|
||||
new_filename = re.sub(r'[<>:"/\\|?*]', '_', new_filename)
|
||||
new_filename = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', new_filename)
|
||||
new_filepath = os.path.join(track_outpath, new_filename)
|
||||
|
||||
if os.path.exists(new_filepath) and os.path.getsize(new_filepath) > 0:
|
||||
@@ -169,23 +167,14 @@ class DownloadWorker(QThread):
|
||||
is_paused_callback = lambda: self.is_paused
|
||||
is_stopped_callback = lambda: self.is_stopped
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
download_result_details = loop.run_until_complete(downloader.download(
|
||||
download_result_details = downloader.download(
|
||||
query=f"{track.title} {track.artists}",
|
||||
isrc=track.isrc,
|
||||
output_dir=track_outpath,
|
||||
quality="LOSSLESS",
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
))
|
||||
)
|
||||
|
||||
if isinstance(download_result_details, str) and os.path.exists(download_result_details):
|
||||
downloaded_file = download_result_details
|
||||
@@ -208,16 +197,7 @@ class DownloadWorker(QThread):
|
||||
|
||||
self.progress.emit(f"Downloading from Deezer with ISRC: {track.isrc}", 0)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
success = loop.run_until_complete(downloader.download_by_isrc(track.isrc, track_outpath))
|
||||
success = asyncio.run(downloader.download_by_isrc(track.isrc, track_outpath))
|
||||
|
||||
if success:
|
||||
safe_title = "".join(c for c in track.title if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
@@ -311,26 +291,20 @@ class DownloadWorker(QThread):
|
||||
class UpdateDialog(QDialog):
|
||||
def __init__(self, current_version, new_version, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Update Available")
|
||||
self.setWindowTitle("Update Now")
|
||||
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 = QLabel(f"SpotiFLAC v{new_version} Available!")
|
||||
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 = QPushButton("Check")
|
||||
self.update_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.cancel_button = QPushButton("Cancel")
|
||||
self.cancel_button = QPushButton("Later")
|
||||
self.cancel_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
button_box.addButton(self.update_button, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
@@ -343,8 +317,6 @@ class UpdateDialog(QDialog):
|
||||
self.update_button.clicked.connect(self.accept)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
|
||||
|
||||
class TidalStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
error = pyqtSignal(str)
|
||||
@@ -398,7 +370,7 @@ class StatusIndicatorDelegate(QStyledItemDelegate):
|
||||
|
||||
circle_size = 6
|
||||
circle_y = option.rect.center().y() - circle_size // 2
|
||||
circle_x = option.rect.right() - circle_size - 10
|
||||
circle_x = option.rect.right() - circle_size - 5
|
||||
|
||||
painter.save()
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
@@ -422,7 +394,7 @@ class ServiceComboBox(QComboBox):
|
||||
|
||||
self.tidal_status_timer = QTimer(self)
|
||||
self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
|
||||
self.tidal_status_timer.start(6000)
|
||||
self.tidal_status_timer.start(60000)
|
||||
|
||||
self.deezer_status_checker = DeezerStatusChecker()
|
||||
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status)
|
||||
@@ -431,7 +403,7 @@ class ServiceComboBox(QComboBox):
|
||||
|
||||
self.deezer_status_timer = QTimer(self)
|
||||
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
||||
self.deezer_status_timer.start(6000)
|
||||
self.deezer_status_timer.start(60000)
|
||||
|
||||
def setup_items(self):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -458,10 +430,10 @@ class ServiceComboBox(QComboBox):
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap.save(path)
|
||||
|
||||
def update_tidal_service_status(self, is_online):
|
||||
def update_service_status(self, service_id, is_online):
|
||||
for i in range(self.count()):
|
||||
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
if service_id == 'tidal':
|
||||
current_service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
if current_service_id == service_id:
|
||||
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(service_data, dict):
|
||||
service_data['online'] = is_online
|
||||
@@ -469,24 +441,27 @@ class ServiceComboBox(QComboBox):
|
||||
break
|
||||
self.update()
|
||||
|
||||
def update_tidal_service_status(self, is_online):
|
||||
self.update_service_status('tidal', is_online)
|
||||
|
||||
def refresh_tidal_status(self):
|
||||
if hasattr(self, 'tidal_status_checker') and self.tidal_status_checker.isRunning():
|
||||
self.tidal_status_checker.quit()
|
||||
self.tidal_status_checker.wait()
|
||||
|
||||
self.tidal_status_checker = TidalStatusChecker()
|
||||
self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status)
|
||||
self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
|
||||
self.tidal_status_checker.start()
|
||||
|
||||
def update_deezer_service_status(self, is_online):
|
||||
for i in range(self.count()):
|
||||
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
if service_id == 'deezer':
|
||||
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(service_data, dict):
|
||||
service_data['online'] = is_online
|
||||
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
|
||||
break
|
||||
self.update()
|
||||
self.update_service_status('deezer', is_online)
|
||||
|
||||
def refresh_deezer_status(self):
|
||||
if hasattr(self, 'deezer_status_checker') and self.deezer_status_checker.isRunning():
|
||||
self.deezer_status_checker.quit()
|
||||
self.deezer_status_checker.wait()
|
||||
|
||||
self.deezer_status_checker = DeezerStatusChecker()
|
||||
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status)
|
||||
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||
@@ -525,7 +500,7 @@ class QobuzRegionComboBox(QComboBox):
|
||||
|
||||
self.status_timer = QTimer(self)
|
||||
self.status_timer.timeout.connect(self.check_status)
|
||||
self.status_timer.start(10000)
|
||||
self.status_timer.start(60000)
|
||||
|
||||
def setup_items(self):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -566,6 +541,12 @@ class QobuzRegionComboBox(QComboBox):
|
||||
self.update()
|
||||
|
||||
def check_status(self):
|
||||
for region_id, checker in self.status_checkers.items():
|
||||
if checker.isRunning():
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
self.status_checkers.clear()
|
||||
|
||||
for region in self.regions:
|
||||
region_id = region['id']
|
||||
checker = QobuzStatusChecker(region_id)
|
||||
@@ -583,13 +564,13 @@ class QobuzRegionComboBox(QComboBox):
|
||||
class SpotiFLACGUI(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.current_version = "4.1"
|
||||
self.current_version = "4.2"
|
||||
self.tracks = []
|
||||
self.all_tracks = []
|
||||
self.reset_state()
|
||||
|
||||
self.settings = QSettings('SpotiFLAC', 'Settings')
|
||||
self.last_output_path = self.settings.value('output_path', os.path.expanduser("~\\Music"))
|
||||
self.last_output_path = self.settings.value('output_path', str(Path.home() / "Music"))
|
||||
self.last_url = self.settings.value('spotify_url', '')
|
||||
|
||||
self.filename_format = self.settings.value('filename_format', 'title_artist')
|
||||
@@ -598,6 +579,7 @@ class SpotiFLACGUI(QWidget):
|
||||
self.service = self.settings.value('service', 'tidal')
|
||||
self.qobuz_region = self.settings.value('qobuz_region', 'us')
|
||||
self.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
|
||||
self.current_theme_color = self.settings.value('theme_color', '#2196F3')
|
||||
|
||||
self.elapsed_time = QTime(0, 0, 0)
|
||||
self.timer = QTimer(self)
|
||||
@@ -611,6 +593,13 @@ class SpotiFLACGUI(QWidget):
|
||||
if self.check_for_updates:
|
||||
QTimer.singleShot(0, self.check_updates)
|
||||
|
||||
def set_combobox_value(self, combobox, target_value):
|
||||
for i in range(combobox.count()):
|
||||
if combobox.itemData(i, Qt.ItemDataRole.UserRole + 1) == target_value:
|
||||
combobox.setCurrentIndex(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_updates(self):
|
||||
try:
|
||||
response = requests.get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json")
|
||||
@@ -622,9 +611,6 @@ class SpotiFLACGUI(QWidget):
|
||||
dialog = UpdateDialog(self.current_version, new_version, self)
|
||||
result = dialog.exec()
|
||||
|
||||
if dialog.disable_check.isChecked():
|
||||
self.settings.setValue('check_for_updates', False)
|
||||
self.check_for_updates = False
|
||||
if result == QDialog.DialogCode.Accepted:
|
||||
QDesktopServices.openUrl(QUrl("https://github.com/afkarxyz/SpotiFLAC/releases"))
|
||||
|
||||
@@ -682,12 +668,13 @@ class SpotiFLACGUI(QWidget):
|
||||
spotify_label.setFixedWidth(100)
|
||||
|
||||
self.spotify_url = QLineEdit()
|
||||
self.spotify_url.setPlaceholderText("Please enter the Spotify URL")
|
||||
self.spotify_url.setPlaceholderText("Enter Spotify URL")
|
||||
self.spotify_url.setClearButtonEnabled(True)
|
||||
self.spotify_url.setText(self.last_url)
|
||||
self.spotify_url.textChanged.connect(self.save_url)
|
||||
|
||||
self.fetch_btn = QPushButton('Fetch')
|
||||
self.fetch_btn.setFixedWidth(80)
|
||||
self.fetch_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.fetch_btn.clicked.connect(self.fetch_tracks)
|
||||
|
||||
@@ -730,6 +717,7 @@ class SpotiFLACGUI(QWidget):
|
||||
self.setup_dashboard_tab()
|
||||
self.setup_process_tab()
|
||||
self.setup_settings_tab()
|
||||
self.setup_theme_tab()
|
||||
self.setup_about_tab()
|
||||
|
||||
def setup_dashboard_tab(self):
|
||||
@@ -745,6 +733,7 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
self.setup_track_buttons()
|
||||
dashboard_layout.addLayout(self.btn_layout)
|
||||
dashboard_layout.addWidget(self.single_track_container)
|
||||
|
||||
dashboard_tab.setLayout(dashboard_layout)
|
||||
self.tab_widget.addTab(dashboard_tab, "Dashboard")
|
||||
@@ -824,7 +813,7 @@ class SpotiFLACGUI(QWidget):
|
||||
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.setMinimumWidth(120)
|
||||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.download_selected_btn.clicked.connect(self.download_selected)
|
||||
@@ -834,9 +823,30 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
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.addWidget(btn, 1)
|
||||
self.btn_layout.addStretch()
|
||||
|
||||
self.single_track_container = QWidget()
|
||||
single_track_layout = QHBoxLayout(self.single_track_container)
|
||||
single_track_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.single_download_btn = QPushButton('Download')
|
||||
self.single_clear_btn = QPushButton('Clear')
|
||||
|
||||
for btn in [self.single_download_btn, self.single_clear_btn]:
|
||||
btn.setFixedWidth(120)
|
||||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.single_download_btn.clicked.connect(self.download_all)
|
||||
self.single_clear_btn.clicked.connect(self.clear_tracks)
|
||||
|
||||
single_track_layout.addStretch()
|
||||
single_track_layout.addWidget(self.single_download_btn)
|
||||
single_track_layout.addWidget(self.single_clear_btn)
|
||||
single_track_layout.addStretch()
|
||||
|
||||
self.single_track_container.hide()
|
||||
|
||||
def setup_process_tab(self):
|
||||
self.process_tab = QWidget()
|
||||
process_layout = QVBoxLayout()
|
||||
@@ -862,13 +872,19 @@ class SpotiFLACGUI(QWidget):
|
||||
self.stop_btn = QPushButton('Stop')
|
||||
self.pause_resume_btn = QPushButton('Pause')
|
||||
|
||||
self.stop_btn.setFixedWidth(120)
|
||||
self.pause_resume_btn.setFixedWidth(120)
|
||||
|
||||
self.stop_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.pause_resume_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.stop_btn.clicked.connect(self.stop_download)
|
||||
self.pause_resume_btn.clicked.connect(self.toggle_pause_resume)
|
||||
|
||||
control_layout.addStretch()
|
||||
control_layout.addWidget(self.stop_btn)
|
||||
control_layout.addWidget(self.pause_resume_btn)
|
||||
control_layout.addStretch()
|
||||
|
||||
process_layout.addLayout(control_layout)
|
||||
|
||||
@@ -901,6 +917,7 @@ class SpotiFLACGUI(QWidget):
|
||||
self.output_dir.textChanged.connect(self.save_settings)
|
||||
|
||||
self.output_browse = QPushButton('Browse')
|
||||
self.output_browse.setFixedWidth(80)
|
||||
self.output_browse.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.output_browse.clicked.connect(self.browse_output)
|
||||
|
||||
@@ -1008,15 +1025,8 @@ class SpotiFLACGUI(QWidget):
|
||||
settings_layout.addStretch()
|
||||
settings_tab.setLayout(settings_layout)
|
||||
self.tab_widget.addTab(settings_tab, "Settings")
|
||||
for i in range(self.service_dropdown.count()):
|
||||
if self.service_dropdown.itemData(i, Qt.ItemDataRole.UserRole + 1) == self.service:
|
||||
self.service_dropdown.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
for i in range(self.qobuz_region_dropdown.count()):
|
||||
if self.qobuz_region_dropdown.itemData(i, Qt.ItemDataRole.UserRole + 1) == self.qobuz_region:
|
||||
self.qobuz_region_dropdown.setCurrentIndex(i)
|
||||
break
|
||||
self.set_combobox_value(self.service_dropdown, self.service)
|
||||
self.set_combobox_value(self.qobuz_region_dropdown, self.qobuz_region)
|
||||
|
||||
|
||||
|
||||
@@ -1026,18 +1036,189 @@ class SpotiFLACGUI(QWidget):
|
||||
lambda region_id, is_online: self.service_dropdown.update_qobuz_status(region_id, is_online)
|
||||
)
|
||||
|
||||
def setup_theme_tab(self):
|
||||
theme_tab = QWidget()
|
||||
theme_layout = QVBoxLayout()
|
||||
theme_layout.setSpacing(8)
|
||||
theme_layout.setContentsMargins(15, 15, 15, 15)
|
||||
|
||||
grid_layout = QVBoxLayout()
|
||||
|
||||
self.color_buttons = {}
|
||||
|
||||
first_row_palettes = [
|
||||
("Red", [
|
||||
("#FFCDD2", "100"), ("#EF9A9A", "200"), ("#E57373", "300"), ("#EF5350", "400"), ("#F44336", "500"), ("#E53935", "600"), ("#D32F2F", "700"), ("#C62828", "800"), ("#B71C1C", "900"), ("#FF8A80", "A100"), ("#FF5252", "A200"), ("#FF1744", "A400"), ("#D50000", "A700")
|
||||
]),
|
||||
("Pink", [
|
||||
("#F8BBD0", "100"), ("#F48FB1", "200"), ("#F06292", "300"), ("#EC407A", "400"), ("#E91E63", "500"), ("#D81B60", "600"), ("#C2185B", "700"), ("#AD1457", "800"), ("#880E4F", "900"), ("#FF80AB", "A100"), ("#FF4081", "A200"), ("#F50057", "A400"), ("#C51162", "A700")
|
||||
]),
|
||||
("Purple", [
|
||||
("#E1BEE7", "100"), ("#CE93D8", "200"), ("#BA68C8", "300"), ("#AB47BC", "400"), ("#9C27B0", "500"), ("#8E24AA", "600"), ("#7B1FA2", "700"), ("#6A1B9A", "800"), ("#4A148C", "900"), ("#EA80FC", "A100"), ("#E040FB", "A200"), ("#D500F9", "A400"), ("#AA00FF", "A700")
|
||||
])
|
||||
]
|
||||
|
||||
second_row_palettes = [
|
||||
("Deep Purple", [
|
||||
("#D1C4E9", "100"), ("#B39DDB", "200"), ("#9575CD", "300"), ("#7E57C2", "400"), ("#673AB7", "500"), ("#5E35B1", "600"), ("#512DA8", "700"), ("#4527A0", "800"), ("#311B92", "900"), ("#B388FF", "A100"), ("#7C4DFF", "A200"), ("#651FFF", "A400"), ("#6200EA", "A700")
|
||||
]),
|
||||
("Indigo", [
|
||||
("#C5CAE9", "100"), ("#9FA8DA", "200"), ("#7986CB", "300"), ("#5C6BC0", "400"), ("#3F51B5", "500"), ("#3949AB", "600"), ("#303F9F", "700"), ("#283593", "800"), ("#1A237E", "900"), ("#8C9EFF", "A100"), ("#536DFE", "A200"), ("#3D5AFE", "A400"), ("#304FFE", "A700")
|
||||
]),
|
||||
("Blue", [
|
||||
("#BBDEFB", "100"), ("#90CAF9", "200"), ("#64B5F6", "300"), ("#42A5F5", "400"), ("#2196F3", "500"), ("#1E88E5", "600"), ("#1976D2", "700"), ("#1565C0", "800"), ("#0D47A1", "900"), ("#82B1FF", "A100"), ("#448AFF", "A200"), ("#2979FF", "A400"), ("#2962FF", "A700")
|
||||
])
|
||||
]
|
||||
|
||||
third_row_palettes = [
|
||||
("Light Blue", [
|
||||
("#B3E5FC", "100"), ("#81D4FA", "200"), ("#4FC3F7", "300"), ("#29B6F6", "400"), ("#03A9F4", "500"), ("#039BE5", "600"), ("#0288D1", "700"), ("#0277BD", "800"), ("#01579B", "900"), ("#80D8FF", "A100"), ("#40C4FF", "A200"), ("#00B0FF", "A400"), ("#0091EA", "A700")
|
||||
]),
|
||||
("Cyan", [
|
||||
("#B2EBF2", "100"), ("#80DEEA", "200"), ("#4DD0E1", "300"), ("#26C6DA", "400"), ("#00BCD4", "500"), ("#00ACC1", "600"), ("#0097A7", "700"), ("#00838F", "800"), ("#006064", "900"), ("#84FFFF", "A100"), ("#18FFFF", "A200"), ("#00E5FF", "A400"), ("#00B8D4", "A700")
|
||||
]),
|
||||
("Teal", [
|
||||
("#B2DFDB", "100"), ("#80CBC4", "200"), ("#4DB6AC", "300"), ("#26A69A", "400"), ("#009688", "500"), ("#00897B", "600"), ("#00796B", "700"), ("#00695C", "800"), ("#004D40", "900"), ("#A7FFEB", "A100"), ("#64FFDA", "A200"), ("#1DE9B6", "A400"), ("#00BFA5", "A700")
|
||||
])
|
||||
]
|
||||
|
||||
fourth_row_palettes = [
|
||||
("Green", [
|
||||
("#C8E6C9", "100"), ("#A5D6A7", "200"), ("#81C784", "300"), ("#66BB6A", "400"), ("#4CAF50", "500"), ("#43A047", "600"), ("#388E3C", "700"), ("#2E7D32", "800"), ("#1B5E20", "900"), ("#B9F6CA", "A100"), ("#69F0AE", "A200"), ("#00E676", "A400"), ("#00C853", "A700")
|
||||
]),
|
||||
("Light Green", [
|
||||
("#DCEDC8", "100"), ("#C5E1A5", "200"), ("#AED581", "300"), ("#9CCC65", "400"), ("#8BC34A", "500"), ("#7CB342", "600"), ("#689F38", "700"), ("#558B2F", "800"), ("#33691E", "900"), ("#CCFF90", "A100"), ("#B2FF59", "A200"), ("#76FF03", "A400"), ("#64DD17", "A700")
|
||||
]),
|
||||
("Lime", [
|
||||
("#F0F4C3", "100"), ("#E6EE9C", "200"), ("#DCE775", "300"), ("#D4E157", "400"), ("#CDDC39", "500"), ("#C0CA33", "600"), ("#AFB42B", "700"), ("#9E9D24", "800"), ("#827717", "900"), ("#F4FF81", "A100"), ("#EEFF41", "A200"), ("#C6FF00", "A400"), ("#AEEA00", "A700")
|
||||
])
|
||||
]
|
||||
|
||||
fifth_row_palettes = [
|
||||
("Yellow", [
|
||||
("#FFF9C4", "100"), ("#FFF59D", "200"), ("#FFF176", "300"), ("#FFEE58", "400"), ("#FFEB3B", "500"), ("#FDD835", "600"), ("#FBC02D", "700"), ("#F9A825", "800"), ("#F57F17", "900"), ("#FFFF8D", "A100"), ("#FFFF00", "A200"), ("#FFEA00", "A400"), ("#FFD600", "A700")
|
||||
]),
|
||||
("Amber", [
|
||||
("#FFECB3", "100"), ("#FFE082", "200"), ("#FFD54F", "300"), ("#FFCA28", "400"), ("#FFC107", "500"), ("#FFB300", "600"), ("#FFA000", "700"), ("#FF8F00", "800"), ("#FF6F00", "900"), ("#FFE57F", "A100"), ("#FFD740", "A200"), ("#FFC400", "A400"), ("#FFAB00", "A700")
|
||||
]),
|
||||
("Orange", [
|
||||
("#FFE0B2", "100"), ("#FFCC80", "200"), ("#FFB74D", "300"), ("#FFA726", "400"), ("#FF9800", "500"), ("#FB8C00", "600"), ("#F57C00", "700"), ("#EF6C00", "800"), ("#E65100", "900"), ("#FFD180", "A100"), ("#FFAB40", "A200"), ("#FF9100", "A400"), ("#FF6D00", "A700")
|
||||
])
|
||||
]
|
||||
|
||||
for row_palettes in [first_row_palettes, second_row_palettes, third_row_palettes, fourth_row_palettes, fifth_row_palettes]:
|
||||
row_layout = QHBoxLayout()
|
||||
row_layout.setSpacing(15)
|
||||
|
||||
for palette_name, colors in row_palettes:
|
||||
column_layout = QVBoxLayout()
|
||||
column_layout.setSpacing(3)
|
||||
|
||||
palette_label = QLabel(palette_name)
|
||||
palette_label.setStyleSheet("margin-bottom: 2px;")
|
||||
palette_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
column_layout.addWidget(palette_label)
|
||||
|
||||
color_buttons_layout = QHBoxLayout()
|
||||
color_buttons_layout.setSpacing(3)
|
||||
|
||||
for color_hex, color_name in colors:
|
||||
color_btn = QPushButton()
|
||||
color_btn.setFixedSize(18, 18)
|
||||
|
||||
is_current = color_hex == self.current_theme_color
|
||||
border_style = "2px solid #fff" if is_current else "none"
|
||||
|
||||
color_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {color_hex};
|
||||
border: {border_style};
|
||||
border-radius: 9px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
border: 2px solid #fff;
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
border: 2px solid #fff;
|
||||
}}
|
||||
""")
|
||||
color_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
color_btn.setToolTip(f"{palette_name} {color_name}\n{color_hex}")
|
||||
color_btn.clicked.connect(lambda checked, color=color_hex, btn=color_btn: self.change_theme_color(color, btn))
|
||||
|
||||
self.color_buttons[color_hex] = color_btn
|
||||
|
||||
color_buttons_layout.addWidget(color_btn)
|
||||
|
||||
column_layout.addLayout(color_buttons_layout)
|
||||
row_layout.addLayout(column_layout)
|
||||
|
||||
grid_layout.addLayout(row_layout)
|
||||
|
||||
theme_layout.addLayout(grid_layout)
|
||||
theme_layout.addStretch()
|
||||
|
||||
theme_tab.setLayout(theme_layout)
|
||||
self.tab_widget.addTab(theme_tab, "Theme")
|
||||
|
||||
def change_theme_color(self, color, clicked_btn=None):
|
||||
if hasattr(self, 'color_buttons'):
|
||||
for color_hex, btn in self.color_buttons.items():
|
||||
if color_hex == self.current_theme_color:
|
||||
btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {color_hex};
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
border: 2px solid #fff;
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
border: 2px solid #fff;
|
||||
}}
|
||||
""")
|
||||
break
|
||||
|
||||
self.current_theme_color = color
|
||||
self.settings.setValue('theme_color', color)
|
||||
self.settings.sync()
|
||||
|
||||
if clicked_btn:
|
||||
clicked_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {color};
|
||||
border: 2px solid #fff;
|
||||
border-radius: 9px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
border: 2px solid #fff;
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
border: 2px solid #fff;
|
||||
}}
|
||||
""")
|
||||
|
||||
qdarktheme.setup_theme(
|
||||
custom_colors={
|
||||
"[dark]": {
|
||||
"primary": color,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def setup_about_tab(self):
|
||||
about_tab = QWidget()
|
||||
about_layout = QVBoxLayout()
|
||||
about_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
about_layout.setSpacing(3)
|
||||
about_layout.setSpacing(15)
|
||||
|
||||
sections = [
|
||||
("Check for Updates", "https://github.com/afkarxyz/SpotiFLAC/releases"),
|
||||
("Report an Issue", "https://github.com/afkarxyz/SpotiFLAC/issues")
|
||||
("Check for Updates", "Check", "https://github.com/afkarxyz/SpotiFLAC/releases"),
|
||||
("Report an Issue", "Report", "https://github.com/afkarxyz/SpotiFLAC/issues")
|
||||
]
|
||||
|
||||
for title, url in sections:
|
||||
for title, button_text, url in sections:
|
||||
section_widget = QWidget()
|
||||
section_layout = QVBoxLayout(section_widget)
|
||||
section_layout.setSpacing(10)
|
||||
@@ -1048,39 +1229,21 @@ class SpotiFLACGUI(QWidget):
|
||||
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 = QPushButton(button_text)
|
||||
button.setFixedSize(120, 25)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.clicked.connect(lambda _, url=url: QDesktopServices.openUrl(QUrl(url if url.startswith(('http://', 'https://')) else f'https://{url}')))
|
||||
section_layout.addWidget(button, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
about_layout.addWidget(section_widget)
|
||||
|
||||
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("v4.1 | July 2025")
|
||||
footer_label.setStyleSheet("font-size: 12px; margin-top: 10px;")
|
||||
footer_label = QLabel(f"v{self.current_version} | July 2025")
|
||||
footer_label.setStyleSheet("font-size: 12px; margin-top: 20px;")
|
||||
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
about_tab.setLayout(about_layout)
|
||||
self.tab_widget.addTab(about_tab, "About")
|
||||
|
||||
def on_service_changed(self, index):
|
||||
service = self.service_dropdown.currentData()
|
||||
self.service = service
|
||||
@@ -1281,8 +1444,7 @@ class SpotiFLACGUI(QWidget):
|
||||
'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"]
|
||||
}
|
||||
'total_tracks': playlist_data["playlist_info"]["tracks"]["total"] }
|
||||
self.update_display_after_fetch(metadata)
|
||||
|
||||
def update_display_after_fetch(self, metadata):
|
||||
@@ -1364,21 +1526,30 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
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')
|
||||
for btn in [self.download_selected_btn, self.download_all_btn, self.remove_btn, self.clear_btn]:
|
||||
btn.hide()
|
||||
|
||||
self.single_track_container.show()
|
||||
|
||||
self.single_download_btn.setEnabled(True)
|
||||
self.single_clear_btn.setEnabled(True)
|
||||
|
||||
else:
|
||||
self.single_track_container.hide()
|
||||
|
||||
self.download_selected_btn.show()
|
||||
self.download_all_btn.show()
|
||||
self.remove_btn.show()
|
||||
self.clear_btn.show()
|
||||
|
||||
self.download_all_btn.setText('Download All')
|
||||
self.clear_btn.setText('Clear')
|
||||
|
||||
self.download_all_btn.show()
|
||||
self.clear_btn.show()
|
||||
self.download_all_btn.setMinimumWidth(120)
|
||||
self.clear_btn.setMinimumWidth(120)
|
||||
|
||||
self.download_selected_btn.setEnabled(True)
|
||||
self.download_all_btn.setEnabled(True)
|
||||
self.download_selected_btn.setEnabled(True)
|
||||
self.download_all_btn.setEnabled(True)
|
||||
|
||||
def hide_track_buttons(self):
|
||||
buttons = [
|
||||
@@ -1390,6 +1561,9 @@ class SpotiFLACGUI(QWidget):
|
||||
for btn in buttons:
|
||||
btn.hide()
|
||||
|
||||
if hasattr(self, 'single_track_container'):
|
||||
self.single_track_container.hide()
|
||||
|
||||
def download_selected(self):
|
||||
if self.is_single_track:
|
||||
self.download_all()
|
||||
@@ -1427,7 +1601,6 @@ class SpotiFLACGUI(QWidget):
|
||||
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()
|
||||
qobuz_region = self.qobuz_region_dropdown.currentData() if service == "qobuz" else "us"
|
||||
@@ -1454,6 +1627,12 @@ class SpotiFLACGUI(QWidget):
|
||||
def update_ui_for_download_start(self):
|
||||
self.download_selected_btn.setEnabled(False)
|
||||
self.download_all_btn.setEnabled(False)
|
||||
|
||||
if hasattr(self, 'single_download_btn'):
|
||||
self.single_download_btn.setEnabled(False)
|
||||
if hasattr(self, 'single_clear_btn'):
|
||||
self.single_clear_btn.setEnabled(False)
|
||||
|
||||
self.stop_btn.show()
|
||||
self.pause_resume_btn.show()
|
||||
self.progress_bar.show()
|
||||
@@ -1482,7 +1661,6 @@ class SpotiFLACGUI(QWidget):
|
||||
self.log_output.append(message)
|
||||
else:
|
||||
self.log_output.append(message)
|
||||
|
||||
if percentage > 0 and not "Download progress:" in message:
|
||||
self.progress_bar.setValue(percentage)
|
||||
|
||||
@@ -1502,6 +1680,11 @@ class SpotiFLACGUI(QWidget):
|
||||
self.download_selected_btn.setEnabled(True)
|
||||
self.download_all_btn.setEnabled(True)
|
||||
|
||||
if hasattr(self, 'single_download_btn'):
|
||||
self.single_download_btn.setEnabled(True)
|
||||
if hasattr(self, 'single_clear_btn'):
|
||||
self.single_clear_btn.setEnabled(True)
|
||||
|
||||
if success:
|
||||
self.log_output.append(f"\nStatus: {message}")
|
||||
if failed_tracks:
|
||||
@@ -1558,6 +1741,31 @@ class SpotiFLACGUI(QWidget):
|
||||
self.timer.stop()
|
||||
self.time_label.hide()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if hasattr(self, 'timer'):
|
||||
self.timer.stop()
|
||||
|
||||
if hasattr(self, 'service_dropdown'):
|
||||
for attr_name in ['tidal_status_checker', 'deezer_status_checker']:
|
||||
if hasattr(self.service_dropdown, attr_name):
|
||||
checker = getattr(self.service_dropdown, attr_name)
|
||||
if checker.isRunning():
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
|
||||
if hasattr(self, 'qobuz_region_dropdown'):
|
||||
for checker in self.qobuz_region_dropdown.status_checkers.values():
|
||||
if checker.isRunning():
|
||||
checker.quit()
|
||||
checker.wait()
|
||||
|
||||
if hasattr(self, 'worker') and self.worker and self.worker.isRunning():
|
||||
self.worker.stop()
|
||||
self.worker.quit()
|
||||
self.worker.wait()
|
||||
|
||||
event.accept()
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
@@ -1568,6 +1776,17 @@ if __name__ == '__main__':
|
||||
pass
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
settings = QSettings('SpotiFLAC', 'Settings')
|
||||
theme_color = settings.value('theme_color', '#2196F3')
|
||||
|
||||
qdarktheme.setup_theme(
|
||||
custom_colors={
|
||||
"[dark]": {
|
||||
"primary": theme_color,
|
||||
}
|
||||
}
|
||||
)
|
||||
ex = SpotiFLACGUI()
|
||||
ex.show()
|
||||
sys.exit(app.exec())
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
+25
-20
@@ -170,27 +170,22 @@ class DeezerDownloader:
|
||||
|
||||
print("Downloading FLAC file...")
|
||||
try:
|
||||
response = self.session.get(flac_url, stream=True)
|
||||
response = self.session.get(flac_url)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
print(f"File size: {total_size} bytes ({total_size / (1024*1024):.2f} MB)")
|
||||
|
||||
safe_title = "".join(c for c in metadata.get('title', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
safe_artist = "".join(c for c in metadata.get('artists', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
filename = f"{safe_artist} - {safe_title}.flac"
|
||||
file_path = os.path.join(output_dir, filename)
|
||||
|
||||
downloaded = 0
|
||||
with open(file_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if self.progress_callback and total_size > 0:
|
||||
current_mb = downloaded / (1024 * 1024)
|
||||
total_mb = total_size / (1024 * 1024)
|
||||
percent = (downloaded / total_size) * 100
|
||||
self.progress_callback(downloaded, total_size)
|
||||
f.write(response.content)
|
||||
|
||||
downloaded = len(response.content)
|
||||
print(f"File size: {downloaded} bytes ({downloaded / (1024*1024):.2f} MB)")
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded, downloaded)
|
||||
|
||||
print(f"Downloaded: {file_path}")
|
||||
|
||||
@@ -214,19 +209,29 @@ class DeezerDownloader:
|
||||
return False
|
||||
|
||||
async def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python deezerDL.py <ISRC>")
|
||||
print("Example: python deezerDL.py USUM72409273")
|
||||
return
|
||||
|
||||
isrc = sys.argv[1]
|
||||
print("=== DeezerDL - Deezer Downloader ===")
|
||||
downloader = DeezerDownloader()
|
||||
|
||||
success = await downloader.download_by_isrc(isrc)
|
||||
isrc = "USAT22409172"
|
||||
output_dir = "."
|
||||
|
||||
success = await downloader.download_by_isrc(isrc, output_dir)
|
||||
if success:
|
||||
print("Download completed successfully!")
|
||||
else:
|
||||
print("Download failed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
import sys
|
||||
if sys.platform == "win32":
|
||||
import os
|
||||
os.system("chcp 65001 > nul")
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-eu" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<g id="eu-d">
|
||||
<g id="eu-b">
|
||||
<path id="eu-a" d="m0-1-.3 1 .5.1z"/>
|
||||
<use xlink:href="#eu-a" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
<g id="eu-c">
|
||||
<use xlink:href="#eu-b" transform="rotate(72)"/>
|
||||
<use xlink:href="#eu-b" transform="rotate(144)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-c" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<path fill="#039" d="M0 0h640v480H0z"/>
|
||||
<g fill="#fc0" transform="translate(320 242.3)scale(23.7037)">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="6"/>
|
||||
<g id="eu-e">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" x="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(-144 -2.3 -2.1)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(144 -2.1 -2.3)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -4.7 -2)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -5 .5)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-e" width="100%" height="100%" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 28.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
|
||||
style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FF0000;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFF00;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
|
||||
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#2AA125;stroke:#373435;stroke-width:0.5669;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g id="SVGRepo_bgCarrier">
|
||||
</g>
|
||||
<g id="SVGRepo_tracerCarrier">
|
||||
</g>
|
||||
<g id="Layer_x0020_1">
|
||||
<g id="_1818452274576">
|
||||
<g id="SVGRepo_bgCarrier_00000044893734704698182460000014884511992085247122_">
|
||||
</g>
|
||||
<g id="SVGRepo_tracerCarrier_00000067939915892718314930000001743019108017086612_">
|
||||
</g>
|
||||
<g id="SVGRepo_iconCarrier_00000176000695080737548300000008661679408724292005_">
|
||||
<path d="M407.1,227.2c-54.7-27.6-119.3-43.8-187.6-43.8c-38.9,0-76.5,5.2-112.3,15l3-0.7c-2.1,0.7-4.5,1.1-7,1.1
|
||||
c-13,0-23.5-10.5-23.5-23.5c0-10.5,6.8-19.3,16.3-22.4l0.2-0.1c90.9-26.9,240.6-21.8,335.4,34.6c7.3,4.4,12.1,12.3,12.1,21.3
|
||||
c0,4.4-1.2,8.6-3.2,12.2l0.1-0.1c-5,5.9-12.3,9.6-20.6,9.6c-4.7,0-9-1.2-12.8-3.3L407.1,227.2L407.1,227.2z M404.5,298.9
|
||||
c-3.4,5.7-9.6,9.4-16.6,9.4c-3.8,0-7.4-1.1-10.4-3l0.1,0.1c-46.8-26.8-102.8-42.5-162.5-42.5c-32.9,0-64.6,4.8-94.6,13.7l2.3-0.6
|
||||
c-1.7,0.5-3.7,0.9-5.8,0.9c-10.7,0-19.4-8.7-19.4-19.4c0-8.7,5.7-16,13.5-18.5l0.1,0c30.8-9.1,66.2-14.4,102.8-14.4
|
||||
c68.1,0,132,18.2,187,49.9l-1.8-1c5.1,3.3,8.4,8.9,8.4,15.3C407.7,292.5,406.5,296,404.5,298.9L404.5,298.9L404.5,298.9
|
||||
L404.5,298.9z M373.8,369.3c-2.7,4.6-7.6,7.7-13.3,7.7c-3.2,0-6.1-1-8.6-2.6l0.1,0c-40.9-23-89.7-36.5-141.7-36.5
|
||||
c-29.8,0-58.6,4.5-85.7,12.7l2.1-0.5c-1.1,0.3-2.5,0.5-3.8,0.5c-8.7,0-15.8-7.1-15.8-15.8c0-7.4,5.1-13.6,11.9-15.3l0.1,0
|
||||
c27-8,58-12.6,90.1-12.6c58.1,0,112.6,15.1,159.9,41.7l-1.7-0.9c5.2,2.6,8.6,7.8,8.6,13.8C376,364.3,375.2,367.1,373.8,369.3
|
||||
L373.8,369.3L373.8,369.3L373.8,369.3z M256-0.6L256-0.6C114.6-0.6,0,114,0,255.4s114.6,256,256,256s256-114.6,256-256l0,0
|
||||
C511.6,114.2,397.2-0.2,256-0.6L256-0.6L256-0.6L256-0.6z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="st0" d="M406.9,227.2c0,0,0.1,0,0.1,0.1L406.9,227.2z M107.1,198.5c35.8-9.8,73.4-15,112.3-15
|
||||
c68.3,0,132.8,16.1,187.5,43.7c3.8,2.1,8.2,3.3,12.8,3.3c8.2,0,15.6-3.7,20.5-9.6c0,0,0,0,0,0c2-3.6,3.1-7.7,3.1-12
|
||||
c0-9-4.8-16.8-12.1-21.3c-94.7-56.4-244.5-61.5-335.4-34.6l-0.2,0.1c-9.4,3.1-16.3,11.9-16.3,22.4c0,13,10.5,23.5,23.5,23.5
|
||||
c2.5,0,4.9-0.4,7-1.1L107.1,198.5L107.1,198.5z"/>
|
||||
<path class="st1" d="M401.2,274.3c-55-31.8-118.9-49.9-187-49.9c-36.6,0-72,5.3-102.8,14.4l-0.1,0c-7.9,2.5-13.5,9.9-13.5,18.5
|
||||
c0,10.7,8.7,19.4,19.4,19.4c1.3,0,2.5-0.1,3.6-0.3c29.9-8.8,61.5-13.6,94.3-13.6c59.7,0,115.7,15.7,162.4,42.5l0.1,0.1
|
||||
c3,1.9,6.5,3,10.3,3c7,0,13.1-3.7,16.6-9.4l0,0c2-2.9,3.2-6.5,3.2-10.3c0-6.4-3.3-12-8.4-15.3L401.2,274.3L401.2,274.3z"/>
|
||||
<path class="st2" d="M352,374.4C352,374.4,352,374.4,352,374.4L352,374.4z M373.8,369.3C373.8,369.3,373.7,369.4,373.8,369.3
|
||||
L373.8,369.3L373.8,369.3z M367.8,347.8l-0.4-0.2C367.5,347.6,367.6,347.7,367.8,347.8z M369,348.4
|
||||
c-47.3-26.5-101.8-41.7-159.9-41.7c-32.1,0-63.1,4.6-90.1,12.6l-0.1,0c-6.8,1.8-11.9,8-11.9,15.3c0,8.7,7.1,15.8,15.8,15.8
|
||||
c1,0,1.9-0.1,2.8-0.3c26.8-8.1,55.2-12.4,84.6-12.4c52,0,100.8,13.5,141.7,36.5l0,0c2.4,1.6,5.4,2.6,8.5,2.6
|
||||
c5.7,0,10.6-3.1,13.3-7.7h0c1.4-2.3,2.2-5,2.2-7.9c0-5.9-3.3-11-8.2-13.6L369,348.4L369,348.4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
+14
-34
@@ -124,45 +124,25 @@ class QobuzDownloader:
|
||||
|
||||
print(f"Downloading...")
|
||||
try:
|
||||
with self.session.get(download_url, stream=True, timeout=900) as response, \
|
||||
open(temp_filename, 'wb') as f:
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
response = self.session.get(download_url, timeout=900)
|
||||
response.raise_for_status()
|
||||
|
||||
for chunk in response.iter_content(chunk_size=self.download_chunk_size):
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
f.close()
|
||||
if os.path.exists(temp_filename):
|
||||
os.remove(temp_filename)
|
||||
raise Exception("Download stopped")
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
while is_paused_callback and is_paused_callback():
|
||||
time.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
f.close()
|
||||
if os.path.exists(temp_filename):
|
||||
os.remove(temp_filename)
|
||||
raise Exception("Download stopped")
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
while is_paused_callback and is_paused_callback():
|
||||
time.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - last_update_time >= 1:
|
||||
if total_size > 0:
|
||||
progress_percent = (downloaded_size / total_size) * 100
|
||||
elapsed_time = current_time - start_time
|
||||
speed = downloaded_size / (1024 * 1024 * elapsed_time) if elapsed_time > 0 else 0
|
||||
print(f"{progress_percent:.2f}% - {speed:.2f} MB/s")
|
||||
else:
|
||||
print(f"{downloaded_size / (1024 * 1024):.2f} MB")
|
||||
with open(temp_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
last_update_time = current_time
|
||||
downloaded_size = len(response.content)
|
||||
total_size = downloaded_size
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded_size, total_size)
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded_size, total_size)
|
||||
|
||||
os.rename(temp_filename, output_filename)
|
||||
print("Download complete")
|
||||
|
||||
+94
-121
@@ -3,7 +3,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import httpx
|
||||
import requests
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
|
||||
@@ -35,7 +35,7 @@ class TidalDownloader:
|
||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
||||
|
||||
async def get_access_token(self):
|
||||
def get_access_token(self):
|
||||
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
|
||||
|
||||
payload = {
|
||||
@@ -43,68 +43,67 @@ class TidalDownloader:
|
||||
"grant_type": "client_credentials",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(http2=True) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
url=refresh_url,
|
||||
data=payload,
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
try:
|
||||
response = requests.post(
|
||||
url=refresh_url,
|
||||
data=payload,
|
||||
auth=(self.client_id, self.client_secret),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
token_data = response.json()
|
||||
return token_data.get("access_token")
|
||||
else:
|
||||
return None
|
||||
|
||||
except:
|
||||
if response.status_code == 200:
|
||||
token_data = response.json()
|
||||
return token_data.get("access_token")
|
||||
else:
|
||||
return None
|
||||
|
||||
async def search_tracks(self, query):
|
||||
except:
|
||||
return None
|
||||
|
||||
def search_tracks(self, query):
|
||||
try:
|
||||
tidal_token = await self.get_access_token()
|
||||
tidal_token = self.get_access_token()
|
||||
if not tidal_token:
|
||||
raise Exception("Failed to get access token")
|
||||
|
||||
search_url = f"https://api.tidal.com/v1/search/tracks?query={query}&limit=25&offset=0&countryCode=US"
|
||||
header = {"authorization": f"Bearer {tidal_token}"}
|
||||
|
||||
async with httpx.AsyncClient(http2=True) as client:
|
||||
search_data = await client.get(url=search_url, headers=header)
|
||||
response_data = search_data.json()
|
||||
search_data = requests.get(url=search_url, headers=header, timeout=self.timeout)
|
||||
response_data = search_data.json()
|
||||
|
||||
filtered_items = [{
|
||||
"id": item.get("id"),
|
||||
"title": item.get("title"),
|
||||
"url": item.get("url"),
|
||||
"isrc": item.get("isrc"),
|
||||
"audioQuality": item.get("audioQuality"),
|
||||
"mediaMetadata": item.get("mediaMetadata"),
|
||||
"album": item.get("album", {}),
|
||||
"artists": item.get("artists", []),
|
||||
"artist": item.get("artist", {}),
|
||||
"trackNumber": item.get("trackNumber"),
|
||||
"volumeNumber": item.get("volumeNumber"),
|
||||
"duration": item.get("duration"),
|
||||
"copyright": item.get("copyright"),
|
||||
"explicit": item.get("explicit")
|
||||
} for item in response_data.get("items", [])]
|
||||
filtered_items = [{
|
||||
"id": item.get("id"),
|
||||
"title": item.get("title"),
|
||||
"url": item.get("url"),
|
||||
"isrc": item.get("isrc"),
|
||||
"audioQuality": item.get("audioQuality"),
|
||||
"mediaMetadata": item.get("mediaMetadata"),
|
||||
"album": item.get("album", {}),
|
||||
"artists": item.get("artists", []),
|
||||
"artist": item.get("artist", {}),
|
||||
"trackNumber": item.get("trackNumber"),
|
||||
"volumeNumber": item.get("volumeNumber"),
|
||||
"duration": item.get("duration"),
|
||||
"copyright": item.get("copyright"),
|
||||
"explicit": item.get("explicit")
|
||||
} for item in response_data.get("items", [])]
|
||||
|
||||
return {
|
||||
"limit": response_data.get("limit"),
|
||||
"offset": response_data.get("offset"),
|
||||
"totalNumberOfItems": response_data.get("totalNumberOfItems"),
|
||||
"items": filtered_items
|
||||
}
|
||||
return {
|
||||
"limit": response_data.get("limit"),
|
||||
"offset": response_data.get("offset"),
|
||||
"totalNumberOfItems": response_data.get("totalNumberOfItems"),
|
||||
"items": filtered_items
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Search error: {str(e)}")
|
||||
|
||||
async def get_track_info(self, query, isrc=None):
|
||||
def get_track_info(self, query, isrc=None):
|
||||
print(f"Fetching: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
|
||||
|
||||
try:
|
||||
result = await self.search_tracks(query)
|
||||
result = self.search_tracks(query)
|
||||
|
||||
if not result or not result.get("items"):
|
||||
raise Exception(f"No tracks found for query: {query}")
|
||||
@@ -143,98 +142,72 @@ class TidalDownloader:
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting track info: {str(e)}")
|
||||
|
||||
async def get_download_url(self, track_id, quality="LOSSLESS"):
|
||||
def get_download_url(self, track_id, quality="LOSSLESS"):
|
||||
print("Fetching URL...")
|
||||
download_api_url = f"https://hifi.401658.xyz/track/?id={track_id}&quality={quality}"
|
||||
|
||||
async with httpx.AsyncClient(http2=True, timeout=self.timeout) as client:
|
||||
try:
|
||||
response = await client.get(download_api_url)
|
||||
try:
|
||||
response = requests.get(download_api_url, timeout=self.timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
for item in data:
|
||||
if "OriginalTrackUrl" in item:
|
||||
print("URL found")
|
||||
return {
|
||||
"download_url": item["OriginalTrackUrl"],
|
||||
"track_info": data[0] if data else {}
|
||||
}
|
||||
for item in data:
|
||||
if "OriginalTrackUrl" in item:
|
||||
print("URL found")
|
||||
return {
|
||||
"download_url": item["OriginalTrackUrl"],
|
||||
"track_info": data[0] if data else {}
|
||||
}
|
||||
|
||||
raise Exception("Download URL not found in response")
|
||||
else:
|
||||
raise Exception(f"API returned status code: {response.status_code}")
|
||||
raise Exception("Download URL not found in response")
|
||||
else:
|
||||
raise Exception(f"API returned status code: {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting download URL: {str(e)}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting download URL: {str(e)}")
|
||||
|
||||
async def download_album_art(self, album_id, size="1280x1280"):
|
||||
def download_album_art(self, album_id, size="1280x1280"):
|
||||
try:
|
||||
art_url = f"https://resources.tidal.com/images/{album_id.replace('-', '/')}/{size}.jpg"
|
||||
|
||||
async with httpx.AsyncClient(http2=True, timeout=self.timeout) as client:
|
||||
response = await client.get(art_url)
|
||||
response = requests.get(art_url, timeout=self.timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
else:
|
||||
print(f"Failed to download album art: HTTP {response.status_code}")
|
||||
return None
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
else:
|
||||
print(f"Failed to download album art: HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading album art: {str(e)}")
|
||||
return None
|
||||
|
||||
async def download_file(self, url, filepath, is_paused_callback=None, is_stopped_callback=None):
|
||||
def download_file(self, url, filepath, is_paused_callback=None, is_stopped_callback=None):
|
||||
temp_filepath = filepath + ".part"
|
||||
retry_count = 0
|
||||
|
||||
while retry_count <= self.max_retries:
|
||||
try:
|
||||
async with httpx.AsyncClient(http2=True, timeout=60.0) as client:
|
||||
async with client.stream('GET', url) as response:
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"HTTP {response.status_code}")
|
||||
response = requests.get(url, timeout=60.0)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"HTTP {response.status_code}")
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
with open(temp_filepath, 'wb') as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=self.download_chunk_size):
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
f.close()
|
||||
if os.path.exists(temp_filepath):
|
||||
os.remove(temp_filepath)
|
||||
raise Exception("Download stopped")
|
||||
while is_paused_callback and is_paused_callback():
|
||||
time.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
while is_paused_callback and is_paused_callback():
|
||||
await asyncio.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
f.close()
|
||||
if os.path.exists(temp_filepath):
|
||||
os.remove(temp_filepath)
|
||||
raise Exception("Download stopped")
|
||||
with open(temp_filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
downloaded_size = len(response.content)
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - last_update_time >= 1:
|
||||
if total_size > 0:
|
||||
progress_percent = (downloaded_size / total_size) * 100
|
||||
elapsed_time = current_time - start_time
|
||||
speed = downloaded_size / (1024 * 1024 * elapsed_time) if elapsed_time > 0 else 0
|
||||
print(f"{progress_percent:.2f}% - {speed:.2f} MB/s")
|
||||
else:
|
||||
print(f"{downloaded_size / (1024 * 1024):.2f} MB")
|
||||
|
||||
last_update_time = current_time
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded_size, total_size)
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded_size, downloaded_size)
|
||||
|
||||
os.rename(temp_filepath, filepath)
|
||||
print("Download complete")
|
||||
@@ -252,9 +225,9 @@ class TidalDownloader:
|
||||
|
||||
print(f"Download error (attempt {retry_count}/{self.max_retries}): {str(e)}")
|
||||
print(f"Retrying in {retry_count * 2} seconds...")
|
||||
await asyncio.sleep(retry_count * 2)
|
||||
time.sleep(retry_count * 2)
|
||||
|
||||
async def embed_metadata(self, filepath, track_info, search_info=None):
|
||||
def embed_metadata(self, filepath, track_info, search_info=None):
|
||||
try:
|
||||
print("Embedding metadata...")
|
||||
audio = FLAC(filepath)
|
||||
@@ -325,7 +298,7 @@ class TidalDownloader:
|
||||
audio["COMMENT"] = f"Tidal {track_info['audioQuality']}"
|
||||
|
||||
if album_info.get("cover"):
|
||||
album_art = await self.download_album_art(album_info["cover"])
|
||||
album_art = self.download_album_art(album_info["cover"])
|
||||
if album_art:
|
||||
picture = Picture()
|
||||
picture.data = album_art
|
||||
@@ -343,14 +316,14 @@ class TidalDownloader:
|
||||
print(f"Error embedding metadata: {str(e)}")
|
||||
return False
|
||||
|
||||
async def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None):
|
||||
def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None):
|
||||
if output_dir != ".":
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise Exception(f"Directory error: {e}")
|
||||
|
||||
track_info = await self.get_track_info(query, isrc)
|
||||
track_info = self.get_track_info(query, isrc)
|
||||
track_id = track_info.get("id")
|
||||
|
||||
if not track_id:
|
||||
@@ -376,12 +349,12 @@ class TidalDownloader:
|
||||
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
||||
return output_filename
|
||||
|
||||
download_info = await self.get_download_url(track_id, quality)
|
||||
download_info = self.get_download_url(track_id, quality)
|
||||
download_url = download_info["download_url"]
|
||||
download_track_info = download_info["track_info"]
|
||||
|
||||
print(f"Downloading to: {output_filename}")
|
||||
await self.download_file(
|
||||
self.download_file(
|
||||
download_url,
|
||||
output_filename,
|
||||
is_paused_callback=is_paused_callback,
|
||||
@@ -390,7 +363,7 @@ class TidalDownloader:
|
||||
|
||||
print("Adding metadata...")
|
||||
try:
|
||||
await self.embed_metadata(output_filename, download_track_info, track_info)
|
||||
self.embed_metadata(output_filename, download_track_info, track_info)
|
||||
print("Metadata saved")
|
||||
except Exception as e:
|
||||
print(f"Tagging failed: {e}")
|
||||
@@ -398,7 +371,7 @@ class TidalDownloader:
|
||||
print("Done")
|
||||
return output_filename
|
||||
|
||||
async def main():
|
||||
def main():
|
||||
print("=== TidalDL - Tidal Downloader ===")
|
||||
downloader = TidalDownloader(timeout=30, max_retries=3)
|
||||
|
||||
@@ -407,7 +380,7 @@ async def main():
|
||||
output_dir = "."
|
||||
|
||||
try:
|
||||
downloaded_file = await downloader.download(query, isrc, output_dir)
|
||||
downloaded_file = downloader.download(query, isrc, output_dir)
|
||||
print(f"Success: File saved as {downloaded_file}")
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
@@ -425,4 +398,4 @@ if __name__ == "__main__":
|
||||
except:
|
||||
pass
|
||||
|
||||
asyncio.run(main())
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-us" viewBox="0 0 640 480">
|
||||
<path fill="#bd3d44" d="M0 0h640v480H0"/>
|
||||
<path stroke="#fff" stroke-width="37" d="M0 55.3h640M0 129h640M0 203h640M0 277h640M0 351h640M0 425h640"/>
|
||||
<path fill="#192f5d" d="M0 0h364.8v258.5H0"/>
|
||||
<marker id="us-a" markerHeight="30" markerWidth="30">
|
||||
<path fill="#fff" d="m14 0 9 27L0 10h28L5 27z"/>
|
||||
</marker>
|
||||
<path fill="none" marker-mid="url(#us-a)" d="m0 0 16 11h61 61 61 61 60L47 37h61 61 60 61L16 63h61 61 61 61 60L47 89h61 61 60 61L16 115h61 61 61 61 60L47 141h61 61 60 61L16 166h61 61 61 61 60L47 192h61 61 60 61L16 218h61 61 61 61 60z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
Reference in New Issue
Block a user