Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bddeab0d1 | |||
| 03a30ee09a | |||
| 2d908e2f75 | |||
| e8f7bf7313 | |||
| 1f0922f358 | |||
| 3f267a3fa1 | |||
| 22da74a027 | |||
| 783350fe88 | |||
| 0057d43f46 | |||
| 9928968ffb | |||
| af4f1dd401 | |||
| 3414fadbd3 | |||
| 457f30da99 | |||
| d4e621b36c | |||
| 58a733b790 | |||
| c85ab4bc28 | |||
| dac2e99b5a | |||
| d0f494f582 | |||
| 0542d6e86b | |||
| de798e4807 | |||
| 0e7ba6d029 | |||
| 2306b1f8d2 | |||
| 1b0d67702d | |||
| 00e369677f | |||
| c3e1607ca6 | |||
| 59428e7679 | |||
| 33c4698286 | |||
| 3ac4c34d73 | |||
| 88e303cbe4 | |||
| c13855fadd | |||
| 2b12684960 |
@@ -1,99 +0,0 @@
|
||||
import asyncio
|
||||
import zendriver as zd
|
||||
|
||||
async def get_metadata(page, headless=True):
|
||||
max_attempts = 40
|
||||
attempts = 0
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await page.evaluate("""
|
||||
window.downloadInfo = null;
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
const [url, config] = args;
|
||||
if (url.includes('/api/load?url=%2Fapi%2Ffetch%2Fstream%2Fv2')) {
|
||||
const payload = JSON.parse(config.body);
|
||||
const title = document.querySelector('h1.svelte-6pt9ji').textContent.trim();
|
||||
const artists = Array.from(document.querySelectorAll('h2.svelte-6pt9ji a.normal'))
|
||||
.map(a => a.textContent.trim())
|
||||
.join(', ');
|
||||
const cover = document.querySelector('.svelte-6pt9ji .meta.svelte-6pt9ji a').href;
|
||||
|
||||
window.downloadInfo = {
|
||||
url: payload.url,
|
||||
cover: cover,
|
||||
title: title,
|
||||
artists: artists,
|
||||
token: payload.token.primary,
|
||||
expiry: payload.token.expiry
|
||||
};
|
||||
}
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
""")
|
||||
|
||||
await page.evaluate("""
|
||||
function waitForElement(selector) {
|
||||
return new Promise(resolve => {
|
||||
if (document.querySelector(selector)) {
|
||||
return resolve(document.querySelector(selector));
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(mutations => {
|
||||
if (document.querySelector(selector)) {
|
||||
observer.disconnect();
|
||||
resolve(document.querySelector(selector));
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (!window.location.hostname.includes('lucida.')) return;
|
||||
|
||||
await Promise.race([
|
||||
waitForElement('.d1-track button'),
|
||||
waitForElement('button[class*="download-button"]')
|
||||
]);
|
||||
|
||||
const clickDownloadButton = () => {
|
||||
const button = document.querySelector('.d1-track button') ||
|
||||
document.querySelector('button[class*="download-button"]');
|
||||
if (button) button.click();
|
||||
};
|
||||
|
||||
clickDownloadButton();
|
||||
})();
|
||||
""")
|
||||
|
||||
while attempts < max_attempts:
|
||||
download_info = await page.evaluate("window.downloadInfo")
|
||||
if download_info:
|
||||
return download_info
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
attempts += 1
|
||||
|
||||
raise TimeoutError("Timeout")
|
||||
|
||||
async def main(headless=True):
|
||||
browser = await zd.start(headless=headless)
|
||||
try:
|
||||
track_id = "2plbrEY59IikOBgBGLjaoe"
|
||||
url = f"https://lucida.to/?url=https%3A%2F%2Fopen.spotify.com%2Ftrack%2F{track_id}&country=auto&to=tidal"
|
||||
|
||||
page = await browser.get(url)
|
||||
download_info = await get_metadata(page)
|
||||
print(download_info)
|
||||
return download_info
|
||||
finally:
|
||||
await browser.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -3,15 +3,16 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal, Amazon Music, and Deezer <code>(via Lucida)</code>, as well as Qobuz <code>(via SquidWTF)</code>.
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal & Deezer.
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v2.7/SpotiFLAC.exe)
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.0/SpotiFLAC.exe)
|
||||
|
||||
#
|
||||
|
||||
> [!Note]
|
||||
**Download speed** from Lucida is unpredictable—sometimes fast, sometimes slow. Join their [Discord](https://discord.com/invite/dXEGRWqEbS) for updates.
|
||||
> [!Important]
|
||||
> - Requires **Google Chrome, Chromium, Microsoft Edge,** or **Brave** to use `Deezer`
|
||||
> - If after **Cloudflare** verification nothing happens, use a `VPN`, your country is likely blocked by `corsproxy.io`
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -21,14 +22,8 @@
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
> When **Fallback** is enabled, it will use the backup server `Lucida.su`
|
||||
|
||||
## Lossless Audio Check
|
||||
|
||||

|
||||
|
||||
+251
-198
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import requests
|
||||
import re
|
||||
import asyncio
|
||||
from packaging import version
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
@@ -17,7 +18,9 @@ from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush
|
||||
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
|
||||
from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
|
||||
from getTracks import LucidaDownloader, SquidWTFDownloader, TidalDownloader
|
||||
from qobuzDL import QobuzDownloader
|
||||
from tidalDL import TidalDownloader
|
||||
from deezerDL import DeezerDownloader
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
@@ -53,10 +56,9 @@ class MetadataFetchWorker(QThread):
|
||||
class DownloadWorker(QThread):
|
||||
finished = pyqtSignal(bool, str, list)
|
||||
progress = pyqtSignal(str, int)
|
||||
|
||||
def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False,
|
||||
album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True,
|
||||
use_album_subfolders=False, use_fallback=False, service="amazon", timeout=30, qobuz_region="us"):
|
||||
use_album_subfolders=False, service="tidal", qobuz_region="us"):
|
||||
super().__init__()
|
||||
self.tracks = tracks
|
||||
self.outpath = outpath
|
||||
@@ -67,9 +69,7 @@ class DownloadWorker(QThread):
|
||||
self.filename_format = filename_format
|
||||
self.use_track_numbers = use_track_numbers
|
||||
self.use_album_subfolders = use_album_subfolders
|
||||
self.use_fallback = use_fallback
|
||||
self.service = service
|
||||
self.timeout = timeout
|
||||
self.qobuz_region = qobuz_region
|
||||
self.is_paused = False
|
||||
self.is_stopped = False
|
||||
@@ -78,6 +78,8 @@ class DownloadWorker(QThread):
|
||||
def get_formatted_filename(self, track):
|
||||
if self.filename_format == "artist_title":
|
||||
filename = f"{track.artists} - {track.title}.flac"
|
||||
elif self.filename_format == "title_only":
|
||||
filename = f"{track.title}.flac"
|
||||
else:
|
||||
filename = f"{track.title} - {track.artists}.flac"
|
||||
return re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
@@ -85,11 +87,13 @@ class DownloadWorker(QThread):
|
||||
def run(self):
|
||||
try:
|
||||
if self.service == "qobuz":
|
||||
downloader = SquidWTFDownloader(self.qobuz_region, self.timeout)
|
||||
elif self.service == "tidal_api":
|
||||
downloader = TidalDownloader(timeout=self.timeout)
|
||||
downloader = QobuzDownloader(self.qobuz_region)
|
||||
elif self.service == "tidal":
|
||||
downloader = TidalDownloader()
|
||||
elif self.service == "deezer":
|
||||
downloader = DeezerDownloader()
|
||||
else:
|
||||
downloader = LucidaDownloader(self.use_fallback, self.timeout)
|
||||
downloader = TidalDownloader()
|
||||
|
||||
def progress_update(current, total):
|
||||
if total > 0:
|
||||
@@ -155,16 +159,13 @@ class DownloadWorker(QThread):
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
)
|
||||
elif self.service == "tidal_api":
|
||||
elif self.service == "tidal":
|
||||
if not track.isrc:
|
||||
self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0)
|
||||
self.failed_tracks.append((track.title, track.artists, "No ISRC available"))
|
||||
continue
|
||||
|
||||
self.progress.emit(f"Searching and downloading from Tidal (API) for ISRC: {track.isrc} - {track.title} - {track.artists}", 0)
|
||||
|
||||
import asyncio
|
||||
|
||||
self.progress.emit(f"Searching and downloading from Tidal for ISRC: {track.isrc} - {track.title} - {track.artists}", 0)
|
||||
is_paused_callback = lambda: self.is_paused
|
||||
is_stopped_callback = lambda: self.is_stopped
|
||||
|
||||
@@ -182,7 +183,6 @@ class DownloadWorker(QThread):
|
||||
isrc=track.isrc,
|
||||
output_dir=track_outpath,
|
||||
quality="LOSSLESS",
|
||||
embed_metadata=True,
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
))
|
||||
@@ -193,19 +193,61 @@ class DownloadWorker(QThread):
|
||||
self.progress.emit(f"Download stopped by user for: {track.title}",0)
|
||||
return
|
||||
elif isinstance(download_result_details, dict) and download_result_details.get("success") == False:
|
||||
raise Exception(download_result_details.get("error", "Tidal API download failed"))
|
||||
elif isinstance(download_result_details, dict) and download_result_details.get("status") == "all_skipped" or download_result_details.get("status") == "skipped_exists":
|
||||
raise Exception(download_result_details.get("error", "Tidal download failed"))
|
||||
elif isinstance(download_result_details, dict) and (download_result_details.get("status") == "all_skipped" or download_result_details.get("status") == "skipped_exists"):
|
||||
self.progress.emit(f"File already exists or skipped: {new_filename}",0)
|
||||
downloaded_file = new_filepath
|
||||
else:
|
||||
downloaded_file = None
|
||||
raise Exception(f"Tidal API download failed or returned unexpected result: {download_result_details}")
|
||||
raise Exception(f"Tidal download failed or returned unexpected result: {download_result_details}")
|
||||
elif self.service == "deezer":
|
||||
if not track.isrc:
|
||||
self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0)
|
||||
self.failed_tracks.append((track.title, track.artists, "No ISRC available"))
|
||||
continue
|
||||
|
||||
self.progress.emit(f"Downloading from Deezer with ISRC: {track.isrc}", 0)
|
||||
|
||||
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))
|
||||
|
||||
if success:
|
||||
safe_title = "".join(c for c in track.title if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
safe_artist = "".join(c for c in track.artists if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
expected_filename = f"{safe_artist} - {safe_title}.flac"
|
||||
downloaded_file = os.path.join(track_outpath, expected_filename)
|
||||
|
||||
if not os.path.exists(downloaded_file):
|
||||
import glob
|
||||
flac_files = glob.glob(os.path.join(track_outpath, "*.flac"))
|
||||
if flac_files:
|
||||
downloaded_file = max(flac_files, key=os.path.getctime)
|
||||
else:
|
||||
raise Exception("Downloaded file not found")
|
||||
else:
|
||||
raise Exception("Deezer download failed")
|
||||
else:
|
||||
track_id = track.id
|
||||
self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0)
|
||||
|
||||
metadata = downloader.get_track_info(track_id, self.service)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
metadata = loop.run_until_complete(downloader.get_track_info(track_id, self.service))
|
||||
self.progress.emit(f"Track info received, starting download process", 0)
|
||||
|
||||
is_paused_callback = lambda: self.is_paused
|
||||
@@ -217,21 +259,25 @@ class DownloadWorker(QThread):
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
)
|
||||
|
||||
if self.is_stopped:
|
||||
return
|
||||
|
||||
if downloaded_file == new_filepath:
|
||||
self.progress.emit(f"File already exists: {new_filename}", 0)
|
||||
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
continue
|
||||
if downloaded_file and os.path.exists(downloaded_file):
|
||||
if downloaded_file == new_filepath:
|
||||
self.progress.emit(f"File already exists: {new_filename}", 0)
|
||||
self.progress.emit(f"Skipped: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
continue
|
||||
|
||||
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)
|
||||
if downloaded_file != new_filepath:
|
||||
try:
|
||||
os.rename(downloaded_file, new_filepath)
|
||||
self.progress.emit(f"File renamed to: {new_filename}", 0)
|
||||
except OSError as e:
|
||||
self.progress.emit(f"Warning: Could not rename file {downloaded_file} to {new_filepath}: {str(e)}", 0)
|
||||
pass
|
||||
else:
|
||||
raise Exception(f"Download failed or file not found: {downloaded_file}")
|
||||
|
||||
self.progress.emit(f"Successfully downloaded: {track.title} - {track.artists}",
|
||||
int((i + 1) / total_tracks * 100))
|
||||
@@ -297,26 +343,7 @@ class UpdateDialog(QDialog):
|
||||
self.update_button.clicked.connect(self.accept)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
class ServiceStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(dict)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get("https://lucida.to/api/stats", timeout=5)
|
||||
services_status = {}
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
current_services = data.get('all', {}).get('downloads', {}).get('current', {}).get('services', {})
|
||||
services_status['amazon'] = current_services.get('amazon', 0) > 0
|
||||
services_status['tidal'] = current_services.get('tidal', 0) > 0
|
||||
services_status['deezer'] = current_services.get('deezer', 0) > 0
|
||||
else:
|
||||
self.error.emit(f"Lucida API error: {response.status_code}")
|
||||
|
||||
self.status_updated.emit(services_status)
|
||||
except Exception as e:
|
||||
self.error.emit(f"Error checking Lucida service status: {str(e)}")
|
||||
|
||||
class TidalStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
@@ -324,8 +351,8 @@ class TidalStatusChecker(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get("https://tidal.401658.xyz", timeout=5)
|
||||
is_online = response.status_code == 200
|
||||
response = requests.get("https://hifi.401658.xyz", timeout=5)
|
||||
is_online = response.status_code == 200 or response.status_code == 429
|
||||
self.status_updated.emit(is_online)
|
||||
except Exception as e:
|
||||
self.error.emit(f"Error checking Tidal (API) status: {str(e)}")
|
||||
@@ -347,6 +374,19 @@ class QobuzStatusChecker(QThread):
|
||||
self.error.emit(f"Error checking Qobuz status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class DeezerStatusChecker(QThread):
|
||||
status_updated = pyqtSignal(bool)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
response = requests.get("https://deezmate.com/", timeout=5)
|
||||
is_online = response.status_code == 200
|
||||
self.status_updated.emit(is_online)
|
||||
except Exception as e:
|
||||
self.error.emit(f"Error checking Deezer status: {str(e)}")
|
||||
self.status_updated.emit(False)
|
||||
|
||||
class StatusIndicatorDelegate(QStyledItemDelegate):
|
||||
def paint(self, painter, option, index):
|
||||
item_data = index.data(Qt.ItemDataRole.UserRole)
|
||||
@@ -373,36 +413,33 @@ class ServiceComboBox(QComboBox):
|
||||
self.services_status = {}
|
||||
|
||||
self.setItemDelegate(StatusIndicatorDelegate())
|
||||
|
||||
self.setup_items()
|
||||
|
||||
self.status_checker = ServiceStatusChecker()
|
||||
self.status_checker.status_updated.connect(self.update_service_status)
|
||||
self.status_checker.error.connect(lambda e: print(f"General status check error: {e}"))
|
||||
self.status_checker.start()
|
||||
self.tidal_status_checker = TidalStatusChecker()
|
||||
self.tidal_status_checker.status_updated.connect(self.update_tidal_service_status)
|
||||
self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
|
||||
self.tidal_status_checker.start()
|
||||
|
||||
self.status_timer = QTimer(self)
|
||||
self.status_timer.timeout.connect(self.refresh_status)
|
||||
self.status_timer.start(5000)
|
||||
self.tidal_status_timer = QTimer(self)
|
||||
self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
|
||||
self.tidal_status_timer.start(6000)
|
||||
|
||||
self.tidal_api_status_checker = TidalStatusChecker()
|
||||
self.tidal_api_status_checker.status_updated.connect(self.update_tidal_api_service_status)
|
||||
self.tidal_api_status_checker.error.connect(lambda e: print(f"Tidal (API) status check error: {e}"))
|
||||
self.tidal_api_status_checker.start()
|
||||
self.deezer_status_checker = DeezerStatusChecker()
|
||||
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status)
|
||||
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||
self.deezer_status_checker.start()
|
||||
|
||||
self.tidal_api_status_timer = QTimer(self)
|
||||
self.tidal_api_status_timer.timeout.connect(self.refresh_tidal_api_status)
|
||||
self.tidal_api_status_timer.start(6000)
|
||||
self.deezer_status_timer = QTimer(self)
|
||||
self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
|
||||
self.deezer_status_timer.start(6000)
|
||||
|
||||
def setup_items(self):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
self.services = [
|
||||
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False},
|
||||
{'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False},
|
||||
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False},
|
||||
{'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png', 'online': False},
|
||||
{'id': 'tidal_api', 'name': 'Tidal (API)', 'icon': 'tidal.png', 'online': False}
|
||||
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False},
|
||||
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False}
|
||||
]
|
||||
|
||||
for service in self.services:
|
||||
@@ -416,36 +453,15 @@ class ServiceComboBox(QComboBox):
|
||||
item_index = self.count() - 1
|
||||
self.setItemData(item_index, service['id'], Qt.ItemDataRole.UserRole + 1)
|
||||
self.setItemData(item_index, service, Qt.ItemDataRole.UserRole)
|
||||
|
||||
def create_placeholder_icon(self, path):
|
||||
pixmap = QPixmap(16, 16)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap.save(path)
|
||||
|
||||
def update_service_status(self, status_dict):
|
||||
self.services_status.update(status_dict)
|
||||
|
||||
def update_tidal_service_status(self, is_online):
|
||||
for i in range(self.count()):
|
||||
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
|
||||
if service_id in self.services_status:
|
||||
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(service_data, dict):
|
||||
service_data['online'] = self.services_status[service_id]
|
||||
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
|
||||
|
||||
self.update()
|
||||
|
||||
def refresh_status(self):
|
||||
self.status_checker = ServiceStatusChecker()
|
||||
self.status_checker.status_updated.connect(self.update_service_status)
|
||||
self.status_checker.error.connect(lambda e: print(f"General status check error: {e}"))
|
||||
self.status_checker.start()
|
||||
|
||||
def update_tidal_api_service_status(self, is_online):
|
||||
for i in range(self.count()):
|
||||
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
||||
if service_id == 'tidal_api':
|
||||
if service_id == 'tidal':
|
||||
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(service_data, dict):
|
||||
service_data['online'] = is_online
|
||||
@@ -453,11 +469,28 @@ class ServiceComboBox(QComboBox):
|
||||
break
|
||||
self.update()
|
||||
|
||||
def refresh_tidal_api_status(self):
|
||||
self.tidal_api_status_checker = TidalStatusChecker()
|
||||
self.tidal_api_status_checker.status_updated.connect(self.update_tidal_api_service_status)
|
||||
self.tidal_api_status_checker.error.connect(lambda e: print(f"Tidal (API) status check error: {e}"))
|
||||
self.tidal_api_status_checker.start()
|
||||
def refresh_tidal_status(self):
|
||||
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()
|
||||
|
||||
def refresh_deezer_status(self):
|
||||
self.deezer_status_checker = DeezerStatusChecker()
|
||||
self.deezer_status_checker.status_updated.connect(self.update_deezer_service_status)
|
||||
self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
|
||||
self.deezer_status_checker.start()
|
||||
|
||||
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
|
||||
return super().currentData(role)
|
||||
@@ -550,8 +583,9 @@ class QobuzRegionComboBox(QComboBox):
|
||||
class SpotiFLACGUI(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.current_version = "2.8"
|
||||
self.current_version = "4.1"
|
||||
self.tracks = []
|
||||
self.all_tracks = []
|
||||
self.reset_state()
|
||||
|
||||
self.settings = QSettings('SpotiFLAC', 'Settings')
|
||||
@@ -561,10 +595,8 @@ class SpotiFLACGUI(QWidget):
|
||||
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.service = self.settings.value('service', 'tidal')
|
||||
self.qobuz_region = self.settings.value('qobuz_region', 'us')
|
||||
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)
|
||||
@@ -593,12 +625,11 @@ class SpotiFLACGUI(QWidget):
|
||||
if dialog.disable_check.isChecked():
|
||||
self.settings.setValue('check_for_updates', False)
|
||||
self.check_for_updates = False
|
||||
|
||||
if result == QDialog.DialogCode.Accepted:
|
||||
QDesktopServices.openUrl(QUrl("https://github.com/afkarxyz/SpotiFLAC/releases"))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking for updates: {e}")
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def format_duration(ms):
|
||||
@@ -608,6 +639,7 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
def reset_state(self):
|
||||
self.tracks.clear()
|
||||
self.all_tracks.clear()
|
||||
self.is_album = False
|
||||
self.is_playlist = False
|
||||
self.is_single_track = False
|
||||
@@ -623,11 +655,15 @@ class SpotiFLACGUI(QWidget):
|
||||
self.pause_resume_btn.setText('Pause')
|
||||
self.reset_info_widget()
|
||||
self.hide_track_buttons()
|
||||
if hasattr(self, 'search_input'):
|
||||
self.search_input.clear()
|
||||
if hasattr(self, 'search_widget'):
|
||||
self.search_widget.hide()
|
||||
|
||||
def initUI(self):
|
||||
self.setWindowTitle('SpotiFLAC')
|
||||
self.setFixedWidth(650)
|
||||
self.setFixedHeight(350)
|
||||
self.setMinimumHeight(350)
|
||||
|
||||
icon_path = os.path.join(os.path.dirname(__file__), "icon.svg")
|
||||
if os.path.exists(icon_path):
|
||||
@@ -660,6 +696,27 @@ class SpotiFLACGUI(QWidget):
|
||||
spotify_layout.addWidget(self.fetch_btn)
|
||||
self.main_layout.addLayout(spotify_layout)
|
||||
|
||||
def filter_tracks(self):
|
||||
search_text = self.search_input.text().lower().strip()
|
||||
|
||||
if not search_text:
|
||||
self.tracks = self.all_tracks.copy()
|
||||
else:
|
||||
self.tracks = [
|
||||
track for track in self.all_tracks
|
||||
if (search_text in track.title.lower() or
|
||||
search_text in track.artists.lower() or
|
||||
search_text in track.album.lower())
|
||||
]
|
||||
|
||||
self.update_track_list_display()
|
||||
|
||||
def update_track_list_display(self):
|
||||
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}")
|
||||
|
||||
def browse_output(self):
|
||||
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
|
||||
if directory:
|
||||
@@ -728,10 +785,37 @@ class SpotiFLACGUI(QWidget):
|
||||
text_info_layout.addStretch()
|
||||
|
||||
info_layout.addLayout(text_info_layout, 1)
|
||||
|
||||
self.setup_search_widget()
|
||||
info_layout.addWidget(self.search_widget)
|
||||
|
||||
self.info_widget.setLayout(info_layout)
|
||||
self.info_widget.setFixedHeight(100)
|
||||
self.info_widget.hide()
|
||||
|
||||
def setup_search_widget(self):
|
||||
self.search_widget = QWidget()
|
||||
search_layout = QVBoxLayout()
|
||||
search_layout.setContentsMargins(10, 0, 0, 0)
|
||||
|
||||
search_layout.addStretch()
|
||||
|
||||
search_input_layout = QHBoxLayout()
|
||||
search_input_layout.addStretch()
|
||||
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Search...")
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self.filter_tracks)
|
||||
self.search_input.setFixedWidth(250)
|
||||
|
||||
|
||||
search_input_layout.addWidget(self.search_input)
|
||||
search_layout.addLayout(search_input_layout)
|
||||
|
||||
self.search_widget.setLayout(search_layout)
|
||||
self.search_widget.hide()
|
||||
|
||||
def setup_track_buttons(self):
|
||||
self.btn_layout = QHBoxLayout()
|
||||
self.download_selected_btn = QPushButton('Download Selected')
|
||||
@@ -837,7 +921,6 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
format_layout = QHBoxLayout()
|
||||
format_label = QLabel('Filename Format:')
|
||||
|
||||
self.format_group = QButtonGroup(self)
|
||||
self.title_artist_radio = QRadioButton('Title - Artist')
|
||||
self.title_artist_radio.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
@@ -847,17 +930,25 @@ class SpotiFLACGUI(QWidget):
|
||||
self.artist_title_radio.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.artist_title_radio.toggled.connect(self.save_filename_format)
|
||||
|
||||
self.title_only_radio = QRadioButton('Title')
|
||||
self.title_only_radio.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_only_radio.toggled.connect(self.save_filename_format)
|
||||
|
||||
if hasattr(self, 'filename_format') and self.filename_format == "artist_title":
|
||||
self.artist_title_radio.setChecked(True)
|
||||
elif hasattr(self, 'filename_format') and self.filename_format == "title_only":
|
||||
self.title_only_radio.setChecked(True)
|
||||
else:
|
||||
self.title_artist_radio.setChecked(True)
|
||||
|
||||
self.format_group.addButton(self.title_artist_radio)
|
||||
self.format_group.addButton(self.artist_title_radio)
|
||||
self.format_group.addButton(self.title_only_radio)
|
||||
|
||||
format_layout.addWidget(format_label)
|
||||
format_layout.addWidget(self.title_artist_radio)
|
||||
format_layout.addWidget(self.artist_title_radio)
|
||||
format_layout.addWidget(self.title_only_radio)
|
||||
format_layout.addStretch()
|
||||
file_layout.addLayout(format_layout)
|
||||
|
||||
@@ -894,26 +985,11 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
self.service_dropdown = ServiceComboBox()
|
||||
self.service_dropdown.currentIndexChanged.connect(self.on_service_changed)
|
||||
|
||||
service_fallback_layout.addWidget(service_label)
|
||||
service_fallback_layout.addWidget(self.service_dropdown)
|
||||
|
||||
service_fallback_layout.addSpacing(10)
|
||||
|
||||
self.fallback_checkbox = QCheckBox('Fallback')
|
||||
self.fallback_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.fallback_checkbox.setChecked(self.use_fallback)
|
||||
self.fallback_checkbox.toggled.connect(self.save_fallback_setting)
|
||||
service_fallback_layout.addWidget(self.fallback_checkbox)
|
||||
|
||||
timeout_label = QLabel('Timeout:')
|
||||
self.timeout_input = QLineEdit()
|
||||
self.timeout_input.setText(str(self.timeout_value))
|
||||
self.timeout_input.setFixedWidth(35)
|
||||
self.timeout_input.textChanged.connect(self.save_timeout_setting)
|
||||
service_fallback_layout.addWidget(timeout_label)
|
||||
service_fallback_layout.addWidget(self.timeout_input)
|
||||
|
||||
region_label = QLabel('Region:')
|
||||
self.qobuz_region_dropdown = QobuzRegionComboBox()
|
||||
self.qobuz_region_dropdown.currentIndexChanged.connect(self.save_qobuz_region_setting)
|
||||
@@ -923,6 +999,8 @@ class SpotiFLACGUI(QWidget):
|
||||
region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
|
||||
|
||||
|
||||
service_fallback_layout.addStretch()
|
||||
auth_layout.addLayout(service_fallback_layout)
|
||||
|
||||
@@ -930,7 +1008,6 @@ 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)
|
||||
@@ -941,6 +1018,10 @@ class SpotiFLACGUI(QWidget):
|
||||
self.qobuz_region_dropdown.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
|
||||
|
||||
self.update_service_ui()
|
||||
|
||||
self.qobuz_region_dropdown.status_updated.connect(
|
||||
lambda region_id, is_online: self.service_dropdown.update_qobuz_status(region_id, is_online)
|
||||
)
|
||||
@@ -953,8 +1034,7 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
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")
|
||||
("Report an Issue", "https://github.com/afkarxyz/SpotiFLAC/issues")
|
||||
]
|
||||
|
||||
for title, url in sections:
|
||||
@@ -986,7 +1066,7 @@ class SpotiFLACGUI(QWidget):
|
||||
}
|
||||
""")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.clicked.connect(lambda _, url=url: QDesktopServices.openUrl(QUrl(url)))
|
||||
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)
|
||||
@@ -995,24 +1075,23 @@ class SpotiFLACGUI(QWidget):
|
||||
spacer = QSpacerItem(20, 6, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
about_layout.addItem(spacer)
|
||||
|
||||
footer_label = QLabel("v2.8 | May 2025")
|
||||
footer_label = QLabel("v4.1 | July 2025")
|
||||
footer_label.setStyleSheet("font-size: 12px; margin-top: 10px;")
|
||||
about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
about_tab.setLayout(about_layout)
|
||||
self.tab_widget.addTab(about_tab, "About")
|
||||
|
||||
def on_service_changed(self, index):
|
||||
service = self.service_dropdown.currentData()
|
||||
self.service = service
|
||||
self.settings.setValue('service', service)
|
||||
self.settings.sync()
|
||||
|
||||
timeout_label = None
|
||||
for widget in self.timeout_input.parentWidget().children():
|
||||
if isinstance(widget, QLabel) and widget.text() == "Timeout:":
|
||||
timeout_label = widget
|
||||
break
|
||||
self.update_service_ui()
|
||||
self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}")
|
||||
|
||||
def update_service_ui(self):
|
||||
service = self.service
|
||||
|
||||
region_label = None
|
||||
for widget in self.qobuz_region_dropdown.parentWidget().children():
|
||||
@@ -1021,40 +1100,29 @@ class SpotiFLACGUI(QWidget):
|
||||
break
|
||||
|
||||
if service == "qobuz":
|
||||
self.fallback_checkbox.hide()
|
||||
self.timeout_input.hide()
|
||||
if timeout_label:
|
||||
timeout_label.hide()
|
||||
|
||||
if region_label:
|
||||
region_label.show()
|
||||
self.qobuz_region_dropdown.show()
|
||||
elif service == "tidal_api":
|
||||
self.fallback_checkbox.hide()
|
||||
self.timeout_input.hide()
|
||||
if timeout_label:
|
||||
timeout_label.hide()
|
||||
elif service == "deezer":
|
||||
if region_label:
|
||||
region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
else:
|
||||
self.fallback_checkbox.show()
|
||||
self.timeout_input.show()
|
||||
if timeout_label:
|
||||
timeout_label.show()
|
||||
|
||||
if region_label:
|
||||
region_label.hide()
|
||||
self.qobuz_region_dropdown.hide()
|
||||
|
||||
self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}")
|
||||
|
||||
def save_url(self):
|
||||
self.settings.setValue('spotify_url', self.spotify_url.text().strip())
|
||||
self.settings.sync()
|
||||
|
||||
def save_filename_format(self):
|
||||
self.filename_format = "artist_title" if self.artist_title_radio.isChecked() else "title_artist"
|
||||
if self.artist_title_radio.isChecked():
|
||||
self.filename_format = "artist_title"
|
||||
elif self.title_only_radio.isChecked():
|
||||
self.filename_format = "title_only"
|
||||
else:
|
||||
self.filename_format = "title_artist"
|
||||
self.settings.setValue('filename_format', self.filename_format)
|
||||
self.settings.sync()
|
||||
|
||||
@@ -1062,33 +1130,11 @@ class SpotiFLACGUI(QWidget):
|
||||
self.use_track_numbers = self.track_number_checkbox.isChecked()
|
||||
self.settings.setValue('use_track_numbers', self.use_track_numbers)
|
||||
self.settings.sync()
|
||||
|
||||
def save_album_subfolder_setting(self):
|
||||
self.use_album_subfolders = self.album_subfolder_checkbox.isChecked()
|
||||
self.settings.setValue('use_album_subfolders', self.use_album_subfolders)
|
||||
self.settings.sync()
|
||||
|
||||
def save_fallback_setting(self):
|
||||
self.use_fallback = self.fallback_checkbox.isChecked()
|
||||
self.settings.setValue('use_fallback', self.use_fallback)
|
||||
self.settings.sync()
|
||||
self.log_output.append("Fallback setting saved successfully!")
|
||||
|
||||
def save_timeout_setting(self):
|
||||
try:
|
||||
timeout = int(self.timeout_input.text())
|
||||
if timeout > 0:
|
||||
self.timeout_value = timeout
|
||||
self.settings.setValue('timeout_value', self.timeout_value)
|
||||
self.settings.sync()
|
||||
self.log_output.append(f"Timeout setting saved: {self.timeout_value} seconds")
|
||||
else:
|
||||
self.timeout_input.setText(str(self.timeout_value))
|
||||
self.log_output.append("Timeout must be a positive number")
|
||||
except ValueError:
|
||||
self.timeout_input.setText(str(self.timeout_value))
|
||||
self.log_output.append("Timeout must be a valid number")
|
||||
|
||||
def save_qobuz_region_setting(self):
|
||||
region = self.qobuz_region_dropdown.currentData()
|
||||
self.qobuz_region = region
|
||||
@@ -1096,6 +1142,8 @@ class SpotiFLACGUI(QWidget):
|
||||
self.settings.sync()
|
||||
self.log_output.append(f"Qobuz region setting saved: {self.qobuz_region_dropdown.currentText()}")
|
||||
|
||||
|
||||
|
||||
def save_settings(self):
|
||||
self.settings.setValue('output_path', self.output_dir.text().strip())
|
||||
self.settings.sync()
|
||||
@@ -1149,7 +1197,7 @@ class SpotiFLACGUI(QWidget):
|
||||
def handle_track_metadata(self, track_data):
|
||||
track_id = track_data["external_urls"].split("/")[-1]
|
||||
|
||||
self.tracks = [Track(
|
||||
track = Track(
|
||||
external_urls=track_data["external_urls"],
|
||||
title=track_data["name"],
|
||||
artists=track_data["artists"],
|
||||
@@ -1158,7 +1206,10 @@ class SpotiFLACGUI(QWidget):
|
||||
duration_ms=track_data.get("duration_ms", 0),
|
||||
id=track_id,
|
||||
isrc=track_data.get("isrc", "")
|
||||
)]
|
||||
)
|
||||
|
||||
self.tracks = [track]
|
||||
self.all_tracks = [track]
|
||||
self.is_single_track = True
|
||||
self.is_album = self.is_playlist = False
|
||||
self.album_or_playlist_name = f"{self.tracks[0].title} - {self.tracks[0].artists}"
|
||||
@@ -1190,6 +1241,7 @@ class SpotiFLACGUI(QWidget):
|
||||
isrc=track.get("isrc", "")
|
||||
))
|
||||
|
||||
self.all_tracks = self.tracks.copy()
|
||||
self.is_album = True
|
||||
self.is_playlist = self.is_single_track = False
|
||||
|
||||
@@ -1220,6 +1272,7 @@ class SpotiFLACGUI(QWidget):
|
||||
isrc=track.get("isrc", "")
|
||||
))
|
||||
|
||||
self.all_tracks = self.tracks.copy()
|
||||
self.is_playlist = True
|
||||
self.is_album = self.is_single_track = False
|
||||
|
||||
@@ -1236,10 +1289,10 @@ class SpotiFLACGUI(QWidget):
|
||||
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.search_widget.show()
|
||||
self.update_track_list_display()
|
||||
else:
|
||||
self.search_widget.hide()
|
||||
|
||||
self.update_info_widget(metadata)
|
||||
|
||||
@@ -1345,17 +1398,19 @@ class SpotiFLACGUI(QWidget):
|
||||
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])
|
||||
selected_indices = [self.track_list.row(item) for item in selected_items]
|
||||
self.download_tracks(selected_indices)
|
||||
|
||||
def download_all(self):
|
||||
if self.is_single_track:
|
||||
self.download_tracks([0])
|
||||
else:
|
||||
self.download_tracks(range(self.track_list.count()))
|
||||
self.download_tracks(range(len(self.tracks)))
|
||||
|
||||
def download_tracks(self, indices):
|
||||
self.log_output.clear()
|
||||
outpath = self.output_dir.text()
|
||||
raw_outpath = self.output_dir.text().strip()
|
||||
outpath = os.path.normpath(raw_outpath)
|
||||
if not os.path.exists(outpath):
|
||||
self.log_output.append('Warning: Invalid output directory.')
|
||||
return
|
||||
@@ -1363,7 +1418,8 @@ class SpotiFLACGUI(QWidget):
|
||||
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)
|
||||
name = self.album_or_playlist_name.strip()
|
||||
folder_name = re.sub(r'[<>:"/\\|?*]', '_', name)
|
||||
outpath = os.path.join(outpath, folder_name)
|
||||
os.makedirs(outpath, exist_ok=True)
|
||||
|
||||
@@ -1386,9 +1442,7 @@ class SpotiFLACGUI(QWidget):
|
||||
self.filename_format,
|
||||
self.use_track_numbers,
|
||||
self.use_album_subfolders,
|
||||
self.use_fallback,
|
||||
service,
|
||||
self.timeout_value,
|
||||
qobuz_region
|
||||
)
|
||||
self.worker.finished.connect(self.on_download_finished)
|
||||
@@ -1472,21 +1526,22 @@ class SpotiFLACGUI(QWidget):
|
||||
|
||||
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)
|
||||
selected_items = self.track_list.selectedItems()
|
||||
selected_indices = [self.track_list.row(item) for item in selected_items]
|
||||
|
||||
for index in selected_indices:
|
||||
self.track_list.takeItem(index)
|
||||
self.tracks.pop(index)
|
||||
tracks_to_remove = [self.tracks[i] for i in selected_indices]
|
||||
|
||||
for i, track in enumerate(self.tracks, 1):
|
||||
if self.is_playlist:
|
||||
for track in tracks_to_remove:
|
||||
if track in self.tracks:
|
||||
self.tracks.remove(track)
|
||||
if track in self.all_tracks:
|
||||
self.all_tracks.remove(track)
|
||||
|
||||
if self.is_playlist:
|
||||
for i, track in enumerate(self.all_tracks, 1):
|
||||
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)
|
||||
self.update_track_list_display()
|
||||
|
||||
def clear_tracks(self):
|
||||
self.reset_state()
|
||||
@@ -1506,13 +1561,11 @@ class SpotiFLACGUI(QWidget):
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import os
|
||||
os.system("chcp 65001 > nul")
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not set UTF-8 encoding: {e}")
|
||||
pass
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
ex = SpotiFLACGUI()
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import requests
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from mutagen.flac import FLAC
|
||||
|
||||
class DeezerDownloader:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
self.progress_callback = None
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
def get_track_by_isrc(self, isrc):
|
||||
try:
|
||||
url = f"https://api.deezer.com/2.0/track/isrc:{isrc}"
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if 'error' in data:
|
||||
print(f"Error from Deezer API: {data['error']['message']}")
|
||||
return None
|
||||
|
||||
return data
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error fetching track data: {e}")
|
||||
return None
|
||||
|
||||
def extract_metadata(self, track_data):
|
||||
metadata = {}
|
||||
|
||||
metadata['title'] = track_data.get('title', '')
|
||||
metadata['title_short'] = track_data.get('title_short', '')
|
||||
metadata['duration'] = track_data.get('duration', 0)
|
||||
metadata['track_position'] = track_data.get('track_position', 1)
|
||||
metadata['disk_number'] = track_data.get('disk_number', 1)
|
||||
metadata['isrc'] = track_data.get('isrc', '')
|
||||
metadata['release_date'] = track_data.get('release_date', '')
|
||||
metadata['explicit_lyrics'] = track_data.get('explicit_lyrics', False)
|
||||
|
||||
if 'artist' in track_data:
|
||||
metadata['artist'] = track_data['artist'].get('name', '')
|
||||
metadata['artist_id'] = track_data['artist'].get('id', '')
|
||||
|
||||
if 'contributors' in track_data:
|
||||
artists = []
|
||||
for contributor in track_data['contributors']:
|
||||
if contributor.get('role') == 'Main':
|
||||
artists.append(contributor.get('name', ''))
|
||||
metadata['artists'] = ', '.join(artists) if artists else metadata.get('artist', '')
|
||||
|
||||
if 'album' in track_data:
|
||||
album = track_data['album']
|
||||
metadata['album'] = album.get('title', '')
|
||||
metadata['album_id'] = album.get('id', '')
|
||||
metadata['cover_url'] = album.get('cover_xl', album.get('cover_big', ''))
|
||||
metadata['cover_md5'] = album.get('md5_image', '')
|
||||
|
||||
metadata['deezer_link'] = track_data.get('link', '')
|
||||
metadata['preview_url'] = track_data.get('preview', '')
|
||||
|
||||
return metadata
|
||||
|
||||
def download_cover_art(self, cover_url, filename):
|
||||
if not cover_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
response = self.session.get(cover_url)
|
||||
response.raise_for_status()
|
||||
|
||||
cover_path = f"{filename}_cover.jpg"
|
||||
with open(cover_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return cover_path
|
||||
except Exception as e:
|
||||
print(f"Error downloading cover art: {e}")
|
||||
return None
|
||||
|
||||
def embed_metadata(self, file_path, metadata, cover_path=None):
|
||||
try:
|
||||
audio = FLAC(file_path)
|
||||
|
||||
audio.clear()
|
||||
|
||||
if metadata.get('title'):
|
||||
audio['TITLE'] = metadata['title']
|
||||
if metadata.get('artists'):
|
||||
audio['ARTIST'] = metadata['artists']
|
||||
elif metadata.get('artist'):
|
||||
audio['ARTIST'] = metadata['artist']
|
||||
if metadata.get('album'):
|
||||
audio['ALBUM'] = metadata['album']
|
||||
if metadata.get('release_date'):
|
||||
audio['DATE'] = metadata['release_date']
|
||||
if metadata.get('track_position'):
|
||||
audio['TRACKNUMBER'] = str(metadata['track_position'])
|
||||
if metadata.get('disk_number'):
|
||||
audio['DISCNUMBER'] = str(metadata['disk_number'])
|
||||
if metadata.get('isrc'):
|
||||
audio['ISRC'] = metadata['isrc']
|
||||
|
||||
if cover_path and os.path.exists(cover_path):
|
||||
with open(cover_path, 'rb') as f:
|
||||
cover_data = f.read()
|
||||
|
||||
from mutagen.flac import Picture
|
||||
picture = Picture()
|
||||
picture.type = 3
|
||||
picture.mime = 'image/jpeg'
|
||||
picture.desc = 'Cover'
|
||||
picture.data = cover_data
|
||||
audio.add_picture(picture)
|
||||
|
||||
audio.save()
|
||||
print(f"Metadata embedded successfully in {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error embedding metadata: {e}")
|
||||
|
||||
async def download_by_isrc(self, isrc, output_dir="."):
|
||||
print(f"Fetching track info for ISRC: {isrc}")
|
||||
|
||||
track_data = self.get_track_by_isrc(isrc)
|
||||
if not track_data:
|
||||
print("Failed to get track data from Deezer API")
|
||||
return False
|
||||
|
||||
metadata = self.extract_metadata(track_data)
|
||||
print(f"Found track: {metadata.get('artists', 'Unknown')} - {metadata.get('title', 'Unknown')}")
|
||||
|
||||
track_id = track_data.get('id')
|
||||
if not track_id:
|
||||
print("No track ID found in Deezer API response")
|
||||
return False
|
||||
|
||||
print(f"Using track ID: {track_id}")
|
||||
|
||||
api_url = f"https://api.deezmate.com/dl/{track_id}"
|
||||
print(f"Requesting download links from: {api_url}")
|
||||
|
||||
try:
|
||||
response = self.session.get(api_url)
|
||||
response.raise_for_status()
|
||||
api_data = response.json()
|
||||
|
||||
if not api_data.get('success'):
|
||||
print("API request failed")
|
||||
return False
|
||||
|
||||
links = api_data.get('links', {})
|
||||
flac_url = links.get('flac')
|
||||
|
||||
if not flac_url:
|
||||
print("No FLAC download link found in API response")
|
||||
return False
|
||||
|
||||
print(f"Successfully obtained FLAC download URL")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting download URL from API: {e}")
|
||||
return False
|
||||
|
||||
print("Downloading FLAC file...")
|
||||
try:
|
||||
response = self.session.get(flac_url, stream=True)
|
||||
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)
|
||||
|
||||
print(f"Downloaded: {file_path}")
|
||||
|
||||
cover_path = None
|
||||
if metadata.get('cover_url'):
|
||||
print("Downloading cover art...")
|
||||
cover_path = self.download_cover_art(metadata['cover_url'],
|
||||
os.path.join(output_dir, f"{safe_artist} - {safe_title}"))
|
||||
|
||||
print("Embedding metadata...")
|
||||
self.embed_metadata(file_path, metadata, cover_path)
|
||||
|
||||
if cover_path and os.path.exists(cover_path):
|
||||
os.remove(cover_path)
|
||||
|
||||
print(f"Successfully downloaded and tagged: {filename}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading file: {e}")
|
||||
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]
|
||||
downloader = DeezerDownloader()
|
||||
|
||||
success = await downloader.download_by_isrc(isrc)
|
||||
if success:
|
||||
print("Download completed successfully!")
|
||||
else:
|
||||
print("Download failed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import nodriver as uc
|
||||
import asyncio
|
||||
|
||||
async def download_deezer_track(deezer_link=None, initial_delay=7.5):
|
||||
if deezer_link is None:
|
||||
deezer_link = "https://www.deezer.com/us/track/2947516331"
|
||||
|
||||
browser = None
|
||||
try:
|
||||
browser = await uc.start(headless=False)
|
||||
page = await browser.get("https://deezmate.com/en")
|
||||
|
||||
print("Loading...")
|
||||
await asyncio.sleep(initial_delay)
|
||||
|
||||
input_selector = 'input[placeholder="Paste your Deezer link here..."]'
|
||||
await page.wait_for(input_selector, timeout=15)
|
||||
input_element = await page.select(input_selector)
|
||||
await input_element.clear_input()
|
||||
await input_element.send_keys(deezer_link)
|
||||
print("Link entered")
|
||||
|
||||
await page.evaluate("""
|
||||
window.apiResponse = null;
|
||||
window.originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
return window.originalFetch(...args).then(async response => {
|
||||
if (response.url.includes('api.deezmate.com/dl/')) {
|
||||
try {
|
||||
const data = await response.clone().json();
|
||||
window.apiResponse = data;
|
||||
console.log('Captured API response:', data);
|
||||
} catch (e) {
|
||||
console.log('Error parsing API response:', e);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
""")
|
||||
|
||||
max_retries = 3
|
||||
download_button_clicked = False
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
download_button_selector = 'button.bg-purple.hover\\:bg-purple-dark.cursor-pointer.transition.text-white.rounded-xl.p-2.mt-2.w-full.mb-5'
|
||||
await page.wait_for(download_button_selector, timeout=15)
|
||||
download_button = await page.select(download_button_selector)
|
||||
await download_button.click()
|
||||
print("Processing...")
|
||||
download_button_clicked = True
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
print(f"Turnstile verification failed, retrying... ({attempt + 1}/{max_retries})")
|
||||
await asyncio.sleep(0.5)
|
||||
await page.evaluate("window.apiResponse = null;")
|
||||
else:
|
||||
print("Failed to pass Turnstile verification after all retries")
|
||||
raise e
|
||||
|
||||
if not download_button_clicked:
|
||||
return None
|
||||
|
||||
try:
|
||||
track_download_selector = 'button.bg-purple.text-white.flex.items-center.gap-2.px-3.py-1.rounded-full.hover\\:bg-purple-dark.transition'
|
||||
await page.wait_for(track_download_selector, timeout=15)
|
||||
track_download_button = await page.select(track_download_selector)
|
||||
await track_download_button.click()
|
||||
except Exception as e:
|
||||
print(f"Failed to click track download button: {e}")
|
||||
return None
|
||||
|
||||
print("Getting FLAC URL from API response...")
|
||||
|
||||
api_response = None
|
||||
for i in range(30):
|
||||
api_response = await page.evaluate("window.apiResponse")
|
||||
if api_response:
|
||||
break
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
if not api_response:
|
||||
return None
|
||||
|
||||
def parse_nodriver_response(data):
|
||||
if isinstance(data, list):
|
||||
result = {}
|
||||
for item in data:
|
||||
if isinstance(item, list) and len(item) == 2:
|
||||
key = item[0]
|
||||
value_obj = item[1]
|
||||
if isinstance(value_obj, dict) and 'value' in value_obj:
|
||||
if value_obj.get('type') == 'object':
|
||||
result[key] = parse_nodriver_response(value_obj['value'])
|
||||
else:
|
||||
result[key] = value_obj['value']
|
||||
return result
|
||||
return data
|
||||
|
||||
parsed_response = parse_nodriver_response(api_response)
|
||||
|
||||
if parsed_response.get('success') and parsed_response.get('links'):
|
||||
flac_url = parsed_response['links'].get('flac')
|
||||
if flac_url:
|
||||
print(f"Successfully obtained FLAC download URL: {flac_url}")
|
||||
return flac_url
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
finally:
|
||||
if browser:
|
||||
try:
|
||||
await browser.stop()
|
||||
except:
|
||||
pass
|
||||
|
||||
async def main(deezer_link=None, initial_delay=7.5):
|
||||
flac_url = await download_deezer_track(deezer_link, initial_delay)
|
||||
if not flac_url:
|
||||
print("Failed to download track")
|
||||
return flac_url
|
||||
|
||||
if __name__ == "__main__":
|
||||
uc.loop().run_until_complete(main())
|
||||
+59
-32
@@ -2,37 +2,58 @@ 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, Dict, Any, List
|
||||
import pyotp
|
||||
import base64
|
||||
from random import randrange
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
_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])
|
||||
# https://github.com/visagenull/Spotify-Free
|
||||
def get_random_user_agent():
|
||||
return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}"
|
||||
|
||||
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()
|
||||
def generate_totp():
|
||||
url = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretBytes.json"
|
||||
|
||||
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,
|
||||
)
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f"Failed to fetch TOTP secrets from GitHub. Status: {resp.status_code}")
|
||||
secrets_list = resp.json()
|
||||
|
||||
token_url = 'https://open.spotify.com/get_access_token'
|
||||
latest_entry = max(secrets_list, key=lambda x: x["version"])
|
||||
version = latest_entry["version"]
|
||||
secret_cipher = latest_entry["secret"]
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to fetch secrets from GitHub: {str(e)}")
|
||||
|
||||
processed = [byte ^ ((i % 33) + 9) for i, byte in enumerate(secret_cipher)]
|
||||
processed_str = "".join(map(str, processed))
|
||||
utf8_bytes = processed_str.encode('utf-8')
|
||||
hex_str = utf8_bytes.hex()
|
||||
secret_bytes = bytes.fromhex(hex_str)
|
||||
b32_secret = base64.b32encode(secret_bytes).decode('utf-8')
|
||||
totp = pyotp.TOTP(b32_secret)
|
||||
|
||||
headers = {
|
||||
"Host": "open.spotify.com",
|
||||
"User-Agent": get_random_user_agent(),
|
||||
"Accept": "*/*",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get("https://open.spotify.com/api/server-time", headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f"Failed to get server time. Status code: {resp.status_code}")
|
||||
data = resp.json()
|
||||
server_time = data.get("serverTime")
|
||||
if server_time is None:
|
||||
raise Exception("Failed to fetch server time from Spotify")
|
||||
return totp, server_time, version
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting server time: {str(e)}")
|
||||
|
||||
token_url = 'https://open.spotify.com/api/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/{}'
|
||||
@@ -102,14 +123,20 @@ def get_json_from_api(api_url, access_token):
|
||||
|
||||
def get_access_token():
|
||||
try:
|
||||
totp, timestamp = generate_totp()
|
||||
totp, server_time, totp_version = generate_totp()
|
||||
otp_code = totp.at(int(server_time))
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
|
||||
params = {
|
||||
"reason": "init",
|
||||
"productType": "web-player",
|
||||
"totp": totp,
|
||||
"totpVer": 5,
|
||||
"ts": timestamp,
|
||||
'reason': 'init',
|
||||
'productType': 'web-player',
|
||||
'totp': otp_code,
|
||||
'totpServerTime': server_time,
|
||||
'totpVer': str(totp_version),
|
||||
'sTime': server_time,
|
||||
'cTime': timestamp_ms,
|
||||
'buildVer': 'web-player_2025-07-02_1720000000000_12345678',
|
||||
'buildDate': '2025-07-02'
|
||||
}
|
||||
|
||||
req = requests.get(token_url, headers=headers, params=params, timeout=10)
|
||||
|
||||
-1052
File diff suppressed because it is too large
Load Diff
+271
@@ -0,0 +1,271 @@
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
|
||||
class ProgressCallback:
|
||||
def __call__(self, current, total):
|
||||
if total > 0:
|
||||
percent = (current / total) * 100
|
||||
print(f"\r{percent:.2f}% ({current}/{total})", end="")
|
||||
else:
|
||||
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
||||
|
||||
class QobuzDownloader:
|
||||
def __init__(self, region="us", timeout=30):
|
||||
if region not in ["eu", "us"]:
|
||||
raise ValueError("Region must be either 'us' or 'eu'")
|
||||
|
||||
self.region = region
|
||||
self.timeout = timeout
|
||||
self.session = 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.base_api_url = f"https://{region}.qobuz.squid.wtf/api"
|
||||
self.download_chunk_size = 256 * 1024
|
||||
self.progress_callback = ProgressCallback()
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
def sanitize_filename(self, filename):
|
||||
if not filename:
|
||||
return "Unknown Track"
|
||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
||||
|
||||
def get_track_info(self, isrc):
|
||||
print(f"Fetching: {isrc}")
|
||||
search_url = f"{self.base_api_url}/get-music"
|
||||
params = {'q': isrc, 'offset': 0, 'limit': 10}
|
||||
|
||||
try:
|
||||
response = self.session.get(search_url, params=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
selected_track = None
|
||||
if data and data.get("success"):
|
||||
items = data.get("data", {}).get("tracks", {}).get("items", [])
|
||||
priority = {24: 1, 16: 2}
|
||||
for track in items:
|
||||
if track.get("isrc") == isrc:
|
||||
current_prio = priority.get(track.get("maximum_bit_depth"), 3)
|
||||
if selected_track is None or current_prio < priority.get(selected_track.get("maximum_bit_depth"), 3):
|
||||
selected_track = track
|
||||
if current_prio == 1:
|
||||
break
|
||||
|
||||
if not selected_track:
|
||||
raise Exception(f"Track not found: {isrc}")
|
||||
|
||||
title = selected_track.get('title', 'Unknown')
|
||||
bit_depth = selected_track.get('maximum_bit_depth', 'Unknown')
|
||||
print(f"Found: {title} ({bit_depth}b)")
|
||||
return selected_track
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise Exception(f"Request error: {e}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error: {e}")
|
||||
|
||||
def get_download_url(self, track_id):
|
||||
print("Fetching URL...")
|
||||
download_api_url = f"{self.base_api_url}/download-music"
|
||||
params = {'track_id': track_id, 'quality': 27}
|
||||
|
||||
try:
|
||||
response = self.session.get(download_api_url, params=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data and data.get("success") and data.get("data", {}).get("url"):
|
||||
download_url = data["data"]["url"]
|
||||
print("URL found")
|
||||
return download_url
|
||||
else:
|
||||
error_msg = data.get('error', {}).get('message', 'Unknown API error')
|
||||
raise Exception(f"API error: {error_msg}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise Exception(f"Request error: {e}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error: {e}")
|
||||
|
||||
def download(self, isrc, output_dir=".", 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 = self.get_track_info(isrc)
|
||||
track_id = track_info.get("id")
|
||||
|
||||
if not track_id:
|
||||
raise Exception("No track ID found")
|
||||
|
||||
artist_name = self.sanitize_filename(track_info.get('performer', {}).get('name'))
|
||||
track_title = self.sanitize_filename(track_info.get('title'))
|
||||
output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
|
||||
|
||||
if os.path.exists(output_filename):
|
||||
file_size = os.path.getsize(output_filename)
|
||||
if file_size > 0:
|
||||
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
||||
return output_filename
|
||||
|
||||
download_url = self.get_download_url(track_id)
|
||||
temp_filename = output_filename + ".part"
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
os.rename(temp_filename, output_filename)
|
||||
print("Download complete")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if os.path.exists(temp_filename):
|
||||
os.remove(temp_filename)
|
||||
raise Exception(f"Download failed: {e}")
|
||||
except Exception as e:
|
||||
if os.path.exists(temp_filename):
|
||||
os.remove(temp_filename)
|
||||
raise Exception(f"File error: {e}")
|
||||
|
||||
print("Adding metadata...")
|
||||
try:
|
||||
self._embed_metadata(output_filename, track_info)
|
||||
print("Metadata saved")
|
||||
except Exception as e:
|
||||
print(f"Tagging failed: {e}")
|
||||
|
||||
print(f"Done")
|
||||
return output_filename
|
||||
|
||||
def _embed_metadata(self, filename, track_info):
|
||||
try:
|
||||
audio = FLAC(filename)
|
||||
audio.delete()
|
||||
audio.clear_pictures()
|
||||
|
||||
album_info = track_info.get('album', {})
|
||||
artist = track_info.get('performer', {}).get('name')
|
||||
|
||||
if track_info.get('title'):
|
||||
audio['TITLE'] = track_info['title']
|
||||
if artist:
|
||||
audio['ARTIST'] = artist
|
||||
if album_info.get('title'):
|
||||
audio['ALBUM'] = album_info['title']
|
||||
if album_info.get('artist', {}).get('name', artist):
|
||||
audio['ALBUMARTIST'] = album_info.get('artist', {}).get('name', artist)
|
||||
if track_info.get('track_number'):
|
||||
audio['TRACKNUMBER'] = str(track_info['track_number'])
|
||||
if track_info.get('release_date_original'):
|
||||
audio['DATE'] = track_info['release_date_original']
|
||||
try:
|
||||
audio['YEAR'] = str(datetime.strptime(track_info['release_date_original'], '%Y-%m-%d').year)
|
||||
except ValueError:
|
||||
pass
|
||||
if album_info.get('genre', {}).get('name'):
|
||||
audio['GENRE'] = album_info['genre']['name']
|
||||
if track_info.get('copyright'):
|
||||
audio['COPYRIGHT'] = track_info['copyright']
|
||||
if track_info.get('isrc'):
|
||||
audio['ISRC'] = track_info['isrc']
|
||||
if album_info.get('label', {}).get('name'):
|
||||
audio['ORGANIZATION'] = album_info['label']['name']
|
||||
|
||||
img_info = album_info.get('image', {})
|
||||
cover_url = img_info.get('large') or img_info.get('small') or img_info.get('thumbnail')
|
||||
if cover_url:
|
||||
try:
|
||||
img_response = self.session.get(cover_url, timeout=30)
|
||||
img_response.raise_for_status()
|
||||
mime_type = img_response.headers.get('Content-Type', 'image/jpeg').lower()
|
||||
if mime_type in ['image/jpeg', 'image/png']:
|
||||
picture = Picture()
|
||||
picture.data = img_response.content
|
||||
picture.type = PictureType.COVER_FRONT
|
||||
picture.mime = mime_type
|
||||
audio.add_picture(picture)
|
||||
print("Cover added")
|
||||
except Exception as e:
|
||||
print(f"Cover error: {str(e)}")
|
||||
|
||||
audio.save()
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Metadata error: {e}")
|
||||
|
||||
def main():
|
||||
print("=== QobuzDL - Qobuz Downloader ===")
|
||||
downloader = QobuzDownloader(region="us")
|
||||
|
||||
isrc = "USAT22409172"
|
||||
output_dir = "."
|
||||
|
||||
try:
|
||||
downloaded_file = downloader.download(isrc, output_dir)
|
||||
print(f"Success: File saved as {downloaded_file}")
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
|
||||
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
|
||||
|
||||
main()
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import httpx
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
|
||||
class ProgressCallback:
|
||||
def __call__(self, current, total):
|
||||
if total > 0:
|
||||
percent = (current / total) * 100
|
||||
print(f"\r{percent:.2f}% ({current}/{total})", end="")
|
||||
else:
|
||||
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
||||
|
||||
class TidalDownloader:
|
||||
def __init__(self, timeout=30, max_retries=3):
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.download_chunk_size = 256 * 1024
|
||||
self.progress_callback = ProgressCallback()
|
||||
self.client_id = "zU4XHVVkc2tDPo4t"
|
||||
self.client_secret = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4="
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
|
||||
|
||||
def sanitize_filename(self, filename):
|
||||
if not filename:
|
||||
return "Unknown Track"
|
||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
||||
|
||||
async def get_access_token(self):
|
||||
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
|
||||
|
||||
payload = {
|
||||
"client_id": self.client_id,
|
||||
"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),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
token_data = response.json()
|
||||
return token_data.get("access_token")
|
||||
else:
|
||||
return None
|
||||
|
||||
except:
|
||||
return None
|
||||
|
||||
async def search_tracks(self, query):
|
||||
try:
|
||||
tidal_token = await 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()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Search error: {str(e)}")
|
||||
|
||||
async 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)
|
||||
|
||||
if not result or not result.get("items"):
|
||||
raise Exception(f"No tracks found for query: {query}")
|
||||
|
||||
selected_track = None
|
||||
if isrc:
|
||||
isrc_items = [item for item in result["items"] if item.get("isrc") == isrc]
|
||||
|
||||
if len(isrc_items) > 1:
|
||||
hires_items = []
|
||||
for item in isrc_items:
|
||||
media_metadata = item.get("mediaMetadata", {})
|
||||
tags = media_metadata.get("tags", []) if media_metadata else []
|
||||
if "HIRES_LOSSLESS" in tags:
|
||||
hires_items.append(item)
|
||||
|
||||
if hires_items:
|
||||
selected_track = hires_items[0]
|
||||
else:
|
||||
selected_track = isrc_items[0]
|
||||
elif len(isrc_items) == 1:
|
||||
selected_track = isrc_items[0]
|
||||
else:
|
||||
selected_track = result["items"][0]
|
||||
else:
|
||||
selected_track = result["items"][0]
|
||||
|
||||
if not selected_track:
|
||||
raise Exception(f"Track not found: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
|
||||
|
||||
title = selected_track.get('title', 'Unknown')
|
||||
quality = selected_track.get('audioQuality', 'Unknown')
|
||||
print(f"Found: {title} ({quality})")
|
||||
return selected_track
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting track info: {str(e)}")
|
||||
|
||||
async 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)
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
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)}")
|
||||
|
||||
async 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)
|
||||
|
||||
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):
|
||||
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}")
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
|
||||
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():
|
||||
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")
|
||||
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
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)
|
||||
|
||||
os.rename(temp_filepath, filepath)
|
||||
print("Download complete")
|
||||
return {"success": True, "size": downloaded_size}
|
||||
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
if retry_count > self.max_retries:
|
||||
if os.path.exists(temp_filepath):
|
||||
try:
|
||||
os.remove(temp_filepath)
|
||||
except:
|
||||
pass
|
||||
raise Exception(f"Download error after {self.max_retries} retries: {str(e)}")
|
||||
|
||||
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)
|
||||
|
||||
async def embed_metadata(self, filepath, track_info, search_info=None):
|
||||
try:
|
||||
print("Embedding metadata...")
|
||||
audio = FLAC(filepath)
|
||||
audio.clear()
|
||||
audio.clear_pictures()
|
||||
|
||||
if track_info.get("title"):
|
||||
audio["TITLE"] = track_info["title"]
|
||||
|
||||
artists_list = []
|
||||
if search_info and search_info.get("artists"):
|
||||
for artist in search_info["artists"]:
|
||||
if artist.get("name"):
|
||||
artists_list.append(artist["name"])
|
||||
elif search_info and search_info.get("artist") and search_info["artist"].get("name"):
|
||||
artists_list.append(search_info["artist"]["name"])
|
||||
elif track_info.get("artists"):
|
||||
for artist in track_info["artists"]:
|
||||
if artist.get("name"):
|
||||
artists_list.append(artist["name"])
|
||||
elif track_info.get("artist") and track_info["artist"].get("name"):
|
||||
artists_list.append(track_info["artist"]["name"])
|
||||
|
||||
if artists_list:
|
||||
audio["ARTIST"] = artists_list[0]
|
||||
if len(artists_list) > 1:
|
||||
audio["ALBUMARTIST"] = "; ".join(artists_list)
|
||||
else:
|
||||
audio["ALBUMARTIST"] = artists_list[0]
|
||||
|
||||
album_info = search_info.get("album", {}) if search_info else track_info.get("album", {})
|
||||
if album_info.get("title"):
|
||||
audio["ALBUM"] = album_info["title"]
|
||||
|
||||
if search_info and search_info.get("trackNumber"):
|
||||
audio["TRACKNUMBER"] = str(search_info["trackNumber"])
|
||||
elif track_info.get("trackNumber"):
|
||||
audio["TRACKNUMBER"] = str(track_info["trackNumber"])
|
||||
|
||||
if search_info and search_info.get("volumeNumber"):
|
||||
audio["DISCNUMBER"] = str(search_info["volumeNumber"])
|
||||
elif track_info.get("volumeNumber"):
|
||||
audio["DISCNUMBER"] = str(track_info["volumeNumber"])
|
||||
|
||||
duration = search_info.get("duration") if search_info else track_info.get("duration")
|
||||
if duration:
|
||||
audio["LENGTH"] = str(duration)
|
||||
|
||||
isrc = search_info.get("isrc") if search_info else track_info.get("isrc")
|
||||
if isrc:
|
||||
audio["ISRC"] = isrc
|
||||
|
||||
copyright_info = search_info.get("copyright") if search_info else track_info.get("copyright")
|
||||
if copyright_info:
|
||||
audio["COPYRIGHT"] = copyright_info
|
||||
|
||||
if album_info.get("releaseDate"):
|
||||
audio["DATE"] = album_info["releaseDate"][:4]
|
||||
try:
|
||||
audio["YEAR"] = album_info["releaseDate"][:4]
|
||||
except:
|
||||
pass
|
||||
|
||||
if track_info.get("genre"):
|
||||
audio["GENRE"] = track_info["genre"]
|
||||
|
||||
if track_info.get("audioQuality"):
|
||||
audio["COMMENT"] = f"Tidal {track_info['audioQuality']}"
|
||||
|
||||
if album_info.get("cover"):
|
||||
album_art = await self.download_album_art(album_info["cover"])
|
||||
if album_art:
|
||||
picture = Picture()
|
||||
picture.data = album_art
|
||||
picture.type = PictureType.COVER_FRONT
|
||||
picture.mime = "image/jpeg"
|
||||
picture.desc = "Cover"
|
||||
audio.add_picture(picture)
|
||||
print("Album art embedded")
|
||||
|
||||
audio.save()
|
||||
print(f"Metadata embedded successfully for: {track_info.get('title', 'Unknown')}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
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):
|
||||
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_id = track_info.get("id")
|
||||
|
||||
if not track_id:
|
||||
raise Exception("No track ID found")
|
||||
|
||||
artists_list = []
|
||||
if track_info.get("artists"):
|
||||
for artist in track_info["artists"]:
|
||||
if artist.get("name"):
|
||||
artists_list.append(artist["name"])
|
||||
elif track_info.get("artist") and track_info["artist"].get("name"):
|
||||
artists_list.append(track_info["artist"]["name"])
|
||||
|
||||
artist_name = ", ".join(artists_list) if artists_list else "Unknown Artist"
|
||||
artist_name = self.sanitize_filename(artist_name)
|
||||
track_title = self.sanitize_filename(track_info.get("title", f"track_{track_id}"))
|
||||
|
||||
output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
|
||||
|
||||
if os.path.exists(output_filename):
|
||||
file_size = os.path.getsize(output_filename)
|
||||
if file_size > 0:
|
||||
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_url = download_info["download_url"]
|
||||
download_track_info = download_info["track_info"]
|
||||
|
||||
print(f"Downloading to: {output_filename}")
|
||||
await self.download_file(
|
||||
download_url,
|
||||
output_filename,
|
||||
is_paused_callback=is_paused_callback,
|
||||
is_stopped_callback=is_stopped_callback
|
||||
)
|
||||
|
||||
print("Adding metadata...")
|
||||
try:
|
||||
await self.embed_metadata(output_filename, download_track_info, track_info)
|
||||
print("Metadata saved")
|
||||
except Exception as e:
|
||||
print(f"Tagging failed: {e}")
|
||||
|
||||
print("Done")
|
||||
return output_filename
|
||||
|
||||
async def main():
|
||||
print("=== TidalDL - Tidal Downloader ===")
|
||||
downloader = TidalDownloader(timeout=30, max_retries=3)
|
||||
|
||||
query = "APT."
|
||||
isrc = "USAT22409172"
|
||||
output_dir = "."
|
||||
|
||||
try:
|
||||
downloaded_file = await downloader.download(query, isrc, output_dir)
|
||||
print(f"Success: File saved as {downloaded_file}")
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
|
||||
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())
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2.7"
|
||||
"version": "4.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user