Compare commits

..

29 Commits

Author SHA1 Message Date
afkarxyz 13567802a0 Update v2.1 2025-03-02 15:18:04 +07:00
afkarxyz d704782519 Update v2.0 2025-03-02 14:52:04 +07:00
afkarxyz 884c02278f Update v2.0 2025-03-02 14:51:46 +07:00
afkarxyz d6abe2bae3 Update v2.0 2025-03-02 14:48:15 +07:00
afkarxyz 7b858dd0ce Update v2.0 2025-03-02 14:47:55 +07:00
afkarxyz cfeb9a2ef2 Update v1.9 2025-03-02 14:05:17 +07:00
afkarxyz 81a78832ff Update v1.9 2025-03-02 14:02:16 +07:00
afkarxyz 071f20deff Update README.md 2025-03-02 14:01:46 +07:00
afkarxyz 03de68ac7b Rename to getMetadata.py 2025-03-02 06:45:20 +07:00
afkarxyz 77363f9e61 Update README.md 2025-03-02 06:33:53 +07:00
afkarxyz 2df77120cf Update v1.8 2025-03-02 06:30:12 +07:00
afkarxyz e6e953b2ed Update README.md 2025-03-02 06:18:19 +07:00
afkarxyz 72f17479e8 Update getTracks.py 2025-02-19 15:48:43 +07:00
afkarxyz 9286fba63c Update README.md 2025-02-19 09:42:23 +07:00
afkarxyz 6bf7084959 Update version.json 2025-02-19 09:42:11 +07:00
afkarxyz 03cc3d82a7 Update v1.7 2025-02-19 09:41:58 +07:00
afkarxyz 2acd6fcba1 Update v1.6 2025-02-18 09:46:44 +07:00
afkarxyz 85a5bb2321 Update README.md 2025-02-18 09:45:29 +07:00
afkarxyz 70a955f531 Update README.md 2025-02-18 09:10:34 +07:00
afkarxyz 71c8070ec0 Create version.json 2025-02-18 07:58:39 +07:00
afkarxyz 177bc06b79 Update README.md 2025-02-04 14:24:02 +07:00
afkarxyz 2aec9c0185 Update v1.5 2025-02-04 14:22:51 +07:00
afkarxyz a6a84cf869 Update v1.5 2025-02-04 14:22:32 +07:00
afkarxyz 3577574ad8 Update v1.5 2025-02-04 14:22:01 +07:00
afkarxyz 3696fc95a7 Update README.md 2025-02-04 14:21:20 +07:00
afkarxyz effa462810 Update README.md 2025-01-22 12:50:04 +07:00
afkarxyz 921faefecf Update README.md 2025-01-22 05:39:13 +07:00
afkarxyz a4168450d1 Update README.md 2025-01-22 05:35:54 +07:00
afkarxyz 7ba3efb75b Update README.md 2025-01-22 05:34:59 +07:00
7 changed files with 436 additions and 422 deletions
-99
View File
@@ -1,99 +0,0 @@
import asyncio
import zendriver as zd
async def get_metadata(page):
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;
const artists = Array.from(document.querySelectorAll('h2.svelte-6pt9ji a.normal'))
.map(a => a.textContent)
.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():
browser = await zd.start(headless=False)
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())
-120
View File
@@ -1,120 +0,0 @@
import requests
from tqdm import tqdm
import time
import os
import asyncio
from GetMetadata import main as get_metadata
class TrackDownloader:
def __init__(self):
self.client = requests.Session()
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
async def get_track_info(self):
metadata = await get_metadata()
return metadata
def sanitize_filename(self, filename):
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
filename = filename.replace(char, '')
filename = ' '.join(filename.split())
filename = filename.replace(' ,', ',')
filename = filename.replace(',', ', ')
while ' ' in filename:
filename = filename.replace(' ', ' ')
filename = filename.rsplit('.', 1)
filename[0] = filename[0].strip()
return '.'.join(filename)
def download(self, metadata, output_dir):
track_url = metadata['url']
primary_token = metadata['token']
expiry = metadata['expiry']
print(f"Starting download for: {track_url}")
initial_request = {
"account": {"id": "auto", "type": "country"},
"compat": "false",
"downscale": "original",
"handoff": True,
"metadata": True,
"private": True,
"token": {
"expiry": expiry,
"primary": primary_token
},
"upload": {"enabled": False, "service": "pixeldrain"},
"url": track_url
}
response = self.client.post("https://lucida.to/api/load?url=/api/fetch/stream/v2",
json=initial_request,
headers=self.headers)
csrf_token = response.cookies.get('csrf_token')
if csrf_token:
self.headers['X-CSRF-Token'] = csrf_token
initial_response = response.json()
if not initial_response.get("success", False):
raise Exception(f"Initial request failed: {initial_response.get('error', 'Unknown error')}")
handoff = initial_response["handoff"]
server = initial_response["server"]
file_name = f"{metadata['title']} - {metadata['artists']}.flac"
file_name = self.sanitize_filename(file_name)
completion_url = f"https://{server}.lucida.to/api/fetch/request/{handoff}"
print("Waiting for track processing to complete")
while True:
completion_response = self.client.get(completion_url, headers=self.headers).json()
if completion_response["status"] == "completed":
break
elif completion_response["status"] == "error":
raise Exception(f"API request failed: {completion_response.get('message', 'Unknown error')}")
time.sleep(1)
download_url = f"https://{server}.lucida.to/api/fetch/request/{handoff}/download"
print(f"Starting download of: {file_name}")
response = self.client.get(download_url, stream=True, headers=self.headers)
total_size = int(response.headers.get('content-length', 0))
file_path = os.path.join(output_dir, file_name)
with open(file_path, 'wb') as file, tqdm(
desc=file_name,
total=total_size,
unit='iB',
unit_scale=True,
unit_divisor=1024,
) as progress_bar:
for data in response.iter_content(chunk_size=1024):
size = file.write(data)
progress_bar.update(size)
print(f"Download completed: {file_path}")
return file_path
async def main():
downloader = TrackDownloader()
output_dir = "."
try:
metadata = await downloader.get_track_info()
downloaded_file = downloader.download(metadata, output_dir)
print(f"File downloaded successfully: {downloaded_file}")
except Exception as e:
print(f"An error occurred: {str(e)}")
if __name__ == "__main__":
asyncio.run(main())
+99 -99
View File
@@ -1,99 +1,99 @@
import asyncio import asyncio
import zendriver as zd import zendriver as zd
async def get_metadata(page, headless=True): async def get_metadata(page, headless=True):
max_attempts = 40 max_attempts = 40
attempts = 0 attempts = 0
await asyncio.sleep(2) await asyncio.sleep(2)
await page.evaluate(""" await page.evaluate("""
window.downloadInfo = null; window.downloadInfo = null;
const originalFetch = window.fetch; const originalFetch = window.fetch;
window.fetch = async function(...args) { window.fetch = async function(...args) {
const [url, config] = args; const [url, config] = args;
if (url.includes('/api/load?url=%2Fapi%2Ffetch%2Fstream%2Fv2')) { if (url.includes('/api/load?url=%2Fapi%2Ffetch%2Fstream%2Fv2')) {
const payload = JSON.parse(config.body); const payload = JSON.parse(config.body);
const title = document.querySelector('h1.svelte-6pt9ji').textContent.trim(); const title = document.querySelector('h1.svelte-6pt9ji').textContent.trim();
const artists = Array.from(document.querySelectorAll('h2.svelte-6pt9ji a.normal')) const artists = Array.from(document.querySelectorAll('h2.svelte-6pt9ji a.normal'))
.map(a => a.textContent.trim()) .map(a => a.textContent.trim())
.join(', '); .join(', ');
const cover = document.querySelector('.svelte-6pt9ji .meta.svelte-6pt9ji a').href; const cover = document.querySelector('.svelte-6pt9ji .meta.svelte-6pt9ji a').href;
window.downloadInfo = { window.downloadInfo = {
url: payload.url, url: payload.url,
cover: cover, cover: cover,
title: title, title: title,
artists: artists, artists: artists,
token: payload.token.primary, token: payload.token.primary,
expiry: payload.token.expiry expiry: payload.token.expiry
}; };
} }
return originalFetch.apply(this, args); return originalFetch.apply(this, args);
}; };
""") """)
await page.evaluate(""" await page.evaluate("""
function waitForElement(selector) { function waitForElement(selector) {
return new Promise(resolve => { return new Promise(resolve => {
if (document.querySelector(selector)) { if (document.querySelector(selector)) {
return resolve(document.querySelector(selector)); return resolve(document.querySelector(selector));
} }
const observer = new MutationObserver(mutations => { const observer = new MutationObserver(mutations => {
if (document.querySelector(selector)) { if (document.querySelector(selector)) {
observer.disconnect(); observer.disconnect();
resolve(document.querySelector(selector)); resolve(document.querySelector(selector));
} }
}); });
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
childList: true, childList: true,
subtree: true subtree: true
}); });
}); });
} }
(async () => { (async () => {
if (!window.location.hostname.includes('lucida.')) return; if (!window.location.hostname.includes('lucida.')) return;
await Promise.race([ await Promise.race([
waitForElement('.d1-track button'), waitForElement('.d1-track button'),
waitForElement('button[class*="download-button"]') waitForElement('button[class*="download-button"]')
]); ]);
const clickDownloadButton = () => { const clickDownloadButton = () => {
const button = document.querySelector('.d1-track button') || const button = document.querySelector('.d1-track button') ||
document.querySelector('button[class*="download-button"]'); document.querySelector('button[class*="download-button"]');
if (button) button.click(); if (button) button.click();
}; };
clickDownloadButton(); clickDownloadButton();
})(); })();
""") """)
while attempts < max_attempts: while attempts < max_attempts:
download_info = await page.evaluate("window.downloadInfo") download_info = await page.evaluate("window.downloadInfo")
if download_info: if download_info:
return download_info return download_info
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
attempts += 1 attempts += 1
raise TimeoutError("Timeout") raise TimeoutError("Timeout")
async def main(headless=True): async def main(headless=True):
browser = await zd.start(headless=headless) browser = await zd.start(headless=headless)
try: try:
track_id = "2plbrEY59IikOBgBGLjaoe" track_id = "2plbrEY59IikOBgBGLjaoe"
url = f"https://lucida.to/?url=https%3A%2F%2Fopen.spotify.com%2Ftrack%2F{track_id}&country=auto&to=tidal" 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) page = await browser.get(url)
download_info = await get_metadata(page) download_info = await get_metadata(page)
print(download_info) print(download_info)
return download_info return download_info
finally: finally:
await browser.stop() await browser.stop()
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
+13 -15
View File
@@ -1,29 +1,27 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotifyFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotifyFLAC/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
![spotifyflac](https://github.com/user-attachments/assets/a11fde95-e756-4592-982f-b567d4a85f3c) ![spotiflac](https://github.com/user-attachments/assets/a233a276-14a4-4f4c-b267-f182dd3912a0)
**Spotify FLAC** allows you to download Spotify tracks in true FLAC format through services like Tidal, Amazon Music, Qobuz, and Deezer with the help of Lucida. <div align="center">
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal, Amazon Music and Deezer with the help of Lucida.
</div>
> [!NOTE] ### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v2.0/SpotiFLAC.exe)
> Requires **Google Chrome**
#
> [!WARNING] > [!WARNING]
Sometimes, the **download speed** from Lucida can be fast or slow; it varies unpredictably. Sometimes, the **download speed** from Lucida can be fast or slow; it varies unpredictably.
#### [Download](https://github.com/afkarxyz/SpotifyFLAC/releases/download/v1.4/SpotifyFLAC.exe) Spotify FLAC
## Screenshots ## Screenshots
![image](https://github.com/user-attachments/assets/c2057543-7f15-470e-beeb-2451a3764d15) > When **Fallback** is enabled, it will use the backup server `Lucida.su`
> - When **Headless** is enabled, the browser runs in the background without a graphical interface, improving performance and allowing seamless automation. ![image](https://github.com/user-attachments/assets/3db51367-45dc-470f-8d6e-8f783ebd6340)
> - When **Fallback** is enabled, it will use the backup server Lucida.su
> - **Filename: Title** means the filename format is `Title - Artist`, and vice versa.
> - I highly recommend **Tidal** or **Amazon Music** because `Qobuz` and `Deezer` occasionally experience issues.
![image](https://github.com/user-attachments/assets/75a61cef-05a8-4f2c-b40b-ba5d49885ffe) ![image](https://github.com/user-attachments/assets/a9020973-f79c-40ba-ab76-e4a3955a1ba4)
![image](https://github.com/user-attachments/assets/84dfcfec-7c9d-4b5b-8624-3558cd3155be) ![image](https://github.com/user-attachments/assets/cf4d09dd-144f-4e7f-a204-78aad353cdbf)
## Lossless Audio Check ## Lossless Audio Check
@@ -31,4 +29,4 @@ Sometimes, the **download speed** from Lucida can be fast or slow; it varies unp
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05) ![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
#### [Download](https://github.com/afkarxyz/SpotifyFLAC/releases/download/v0/FLAC-Checker.zip) FLAC Checker #### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) FLAC Checker
+251 -79
View File
@@ -1,16 +1,18 @@
import sys import sys
import asyncio
import os import os
import requests
import time import time
from datetime import datetime
from pathlib import Path from pathlib import Path
from packaging import version
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QProgressBar, QFileDialog, QCheckBox, QRadioButton, QProgressBar, QFileDialog, QCheckBox, QRadioButton,
QGroupBox, QComboBox) QGroupBox, QComboBox, QDialog, QDialogButtonBox,
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QSettings, QSize QStyledItemDelegate, QStyle)
from PyQt6.QtGui import QIcon, QPixmap, QCursor from PyQt6.QtCore import QThread, pyqtSignal, Qt, QSettings, QSize, QTimer, QUrl
from GetMetadata import get_metadata from PyQt6.QtGui import QIcon, QPixmap, QCursor, QDesktopServices, QBrush, QPalette
from LucidaDownloader import TrackDownloader from getTracks import TrackDownloader
class ImageDownloader(QThread): class ImageDownloader(QThread):
finished = pyqtSignal(bytes) finished = pyqtSignal(bytes)
@@ -29,39 +31,25 @@ class MetadataFetcher(QThread):
finished = pyqtSignal(dict) finished = pyqtSignal(dict)
error = pyqtSignal(str) error = pyqtSignal(str)
def __init__(self, url, headless=True, service="tidal", use_fallback=False): def __init__(self, url, service="amazon", use_fallback=False):
super().__init__() super().__init__()
self.url = url self.url = url
self.headless_mode = headless
self.service = service self.service = service
self.use_fallback = use_fallback self.use_fallback = use_fallback
self.max_retries = 3 self.max_retries = 3
self.downloader = TrackDownloader(use_fallback=use_fallback)
def extract_track_id(self, url): def extract_track_id(self, url):
if "track/" in url: if "track/" in url:
return url.split("track/")[1].split("?")[0] return url.split("track/")[1].split("?")[0].split("/")[0]
return None return None
async def fetch_metadata(self, track_id): async def get_track_info_async(self, track_id, service, use_fallback):
import zendriver as zd try:
from asyncio import sleep metadata = await self.downloader.get_track_info(track_id, service, use_fallback)
return metadata
domain = "lucida.su" if self.use_fallback else "lucida.to" except Exception as e:
raise e
for attempt in range(self.max_retries):
try:
lucida_url = f"https://{domain}/?url=https%3A%2F%2Fopen.spotify.com%2Ftrack%2F{track_id}&country=auto&to={self.service}"
browser = await zd.start(headless=self.headless_mode)
try:
page = await browser.get(lucida_url)
return await get_metadata(page)
finally:
await browser.stop()
except Exception as e:
if "refused" in str(e).lower() and attempt < self.max_retries - 1:
await sleep(2 * (attempt + 1))
continue
raise e
def run(self): def run(self):
try: try:
@@ -70,11 +58,30 @@ class MetadataFetcher(QThread):
self.error.emit("Invalid Spotify URL") self.error.emit("Invalid Spotify URL")
return return
metadata = asyncio.run(self.fetch_metadata(track_id)) import asyncio
if metadata: for attempt in range(self.max_retries):
self.finished.emit(metadata) try:
else: metadata = asyncio.run(self.get_track_info_async(
self.error.emit("Failed to fetch track metadata") track_id, self.service, self.use_fallback))
formatted_metadata = {
'title': metadata['title'],
'artists': metadata['artists'],
'cover': metadata['coverArtwork'],
'url': metadata['url'],
'token': metadata['token'],
'duration': metadata.get('durationMs', 0),
'release_date': metadata.get('releaseDate', '')
}
self.finished.emit(formatted_metadata)
return
except Exception as e:
if attempt < self.max_retries - 1:
time.sleep(2 * (attempt + 1))
continue
raise e
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
@@ -128,7 +135,10 @@ class DownloaderWorker(QThread):
time_diff = current_time - self.last_update_time time_diff = current_time - self.last_update_time
if time_diff > 0: if time_diff > 0:
speed = (downloaded_size - self.last_downloaded_size) / time_diff speed = (downloaded_size - self.last_downloaded_size) / time_diff
status = f"Downloading... {self.format_size(downloaded_size)}/{self.format_size(total_size)} | {self.format_speed(speed)}" if downloaded_size == 0 and total_size == 0:
status = "Preparing metadata..."
else:
status = f"Downloading... {self.format_size(downloaded_size)}/{self.format_size(total_size)} | {self.format_speed(speed)}"
self.status.emit(status) self.status.emit(status)
self.last_update_time = current_time self.last_update_time = current_time
@@ -146,44 +156,165 @@ class DownloaderWorker(QThread):
except Exception as e: except Exception as e:
self.error.emit(f"Error: {str(e)}") self.error.emit(f"Error: {str(e)}")
class ServiceStatusChecker(QThread):
status_updated = pyqtSignal(dict)
error = pyqtSignal(str)
def run(self):
try:
response = requests.get("https://lucida.to/api/stats", timeout=5)
if response.status_code == 200:
data = response.json()
services_status = {}
current_services = data.get('all', {}).get('downloads', {}).get('current', {}).get('services', {})
services_status['amazon'] = current_services.get('amazon', 0) > 0
services_status['tidal'] = current_services.get('tidal', 0) > 0
services_status['deezer'] = current_services.get('deezer', 0) > 0
self.status_updated.emit(services_status)
else:
self.error.emit(f"Server returned status code: {response.status_code}")
except Exception as e:
self.error.emit(f"Error checking service status: {str(e)}")
class StatusIndicatorDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
item_data = index.data(Qt.ItemDataRole.UserRole)
is_online = item_data.get('online', False) if item_data else False
super().paint(painter, option, index)
if option.state & QStyle.StateFlag.State_Selected:
text_color = option.palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText)
else:
text_color = option.palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.Text)
indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red
circle_size = 6
circle_y = option.rect.center().y() - circle_size // 2
circle_x = option.rect.right() - circle_size - 10
painter.save()
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(indicator_color))
painter.drawEllipse(circle_x, circle_y, circle_size, circle_size)
painter.restore()
class ServiceComboBox(QComboBox): class ServiceComboBox(QComboBox):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setIconSize(QSize(16, 16)) self.setIconSize(QSize(16, 16))
self.services_status = {}
self.setItemDelegate(StatusIndicatorDelegate())
self.setup_items() self.setup_items()
self.status_checker = ServiceStatusChecker()
self.status_checker.status_updated.connect(self.update_service_status)
self.status_checker.error.connect(lambda e: print(f"Status check error: {e}"))
self.status_checker.start()
self.status_timer = QTimer(self)
self.status_timer.timeout.connect(self.refresh_status)
self.status_timer.start(5000)
def setup_items(self): def setup_items(self):
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
icons_dir = os.path.join(current_dir, 'icons')
if not os.path.exists(icons_dir): self.services = [
os.makedirs(icons_dir) {'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png', 'online': False},
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png', 'online': False},
services = [ {'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png', 'online': False}
{'id': 'tidal', 'name': 'Tidal', 'icon': 'tidal.png'},
{'id': 'amazon', 'name': 'Amazon Music', 'icon': 'amazon.png'},
{'id': 'qobuz', 'name': 'Qobuz', 'icon': 'qobuz.png'},
{'id': 'deezer', 'name': 'Deezer', 'icon': 'deezer.png'}
] ]
for service in services: for service in self.services:
icon_path = os.path.join(icons_dir, service['icon']) icon_path = os.path.join(current_dir, service['icon'])
if not os.path.exists(icon_path): if not os.path.exists(icon_path):
self.create_placeholder_icon(icon_path) self.create_placeholder_icon(icon_path)
icon = QIcon(icon_path) icon = QIcon(icon_path)
self.addItem(icon, service['name'], service['id'])
self.addItem(icon, service['name'])
item_index = self.count() - 1
self.setItemData(item_index, service['id'], Qt.ItemDataRole.UserRole + 1)
self.setItemData(item_index, service, Qt.ItemDataRole.UserRole)
def create_placeholder_icon(self, path): def create_placeholder_icon(self, path):
pixmap = QPixmap(16, 16) pixmap = QPixmap(16, 16)
pixmap.fill(Qt.GlobalColor.transparent) pixmap.fill(Qt.GlobalColor.transparent)
pixmap.save(path) pixmap.save(path)
def update_service_status(self, status_dict):
self.services_status = status_dict
for i in range(self.count()):
service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
if service_id in self.services_status:
service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
if isinstance(service_data, dict):
service_data['online'] = self.services_status[service_id]
self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
self.update()
def refresh_status(self):
self.status_checker = ServiceStatusChecker()
self.status_checker.status_updated.connect(self.update_service_status)
self.status_checker.error.connect(lambda e: print(f"Status check error: {e}"))
self.status_checker.start()
def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
return super().currentData(role)
class SpotifyFlacGUI(QMainWindow): class UpdateDialog(QDialog):
def __init__(self, current_version, new_version, parent=None):
super().__init__(parent)
self.setWindowTitle("Update Available")
self.setFixedWidth(400)
self.setModal(True)
layout = QVBoxLayout()
message = QLabel(f"A new version of SpotiFLAC is available!\n\n"
f"Current version: v{current_version}\n"
f"New version: v{new_version}")
message.setWordWrap(True)
layout.addWidget(message)
self.disable_check = QCheckBox("Turn off update checking")
self.disable_check.setCursor(Qt.CursorShape.PointingHandCursor)
layout.addWidget(self.disable_check)
button_box = QDialogButtonBox()
self.update_button = QPushButton("Update")
self.update_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.cancel_button = QPushButton("Cancel")
self.cancel_button.setCursor(Qt.CursorShape.PointingHandCursor)
button_box.addButton(self.update_button, QDialogButtonBox.ButtonRole.AcceptRole)
button_box.addButton(self.cancel_button, QDialogButtonBox.ButtonRole.RejectRole)
layout.addWidget(button_box)
self.setLayout(layout)
self.update_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
class SpotiFlacGUI(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.settings = QSettings('SpotifyFlac', 'Settings') self.current_version = "2.1"
self.setWindowTitle("Spotify FLAC") self.settings = QSettings('SpotiFlac', 'Settings')
self.setWindowTitle("SpotiFLAC")
self.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
icon_path = os.path.join(os.path.dirname(__file__), "icon.svg") icon_path = os.path.join(os.path.dirname(__file__), "icon.svg")
if os.path.exists(icon_path): if os.path.exists(icon_path):
@@ -202,18 +333,44 @@ class SpotifyFlacGUI(QMainWindow):
self.load_settings() self.load_settings()
self.setup_settings_persistence() self.setup_settings_persistence()
last_url = self.settings.value('last_url', '')
self.url_input.setText(last_url)
self.url_input.textChanged.connect(self.save_url)
if self.check_for_updates:
QTimer.singleShot(0, self.check_updates)
def check_updates(self):
try:
response = requests.get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json")
if response.status_code == 200:
data = response.json()
new_version = data.get("version")
if new_version and version.parse(new_version) > version.parse(self.current_version):
dialog = UpdateDialog(self.current_version, new_version, self)
result = dialog.exec()
if dialog.disable_check.isChecked():
self.settings.setValue('check_for_updates', False)
self.check_for_updates = False
if result == QDialog.DialogCode.Accepted:
QDesktopServices.openUrl(QUrl("https://github.com/afkarxyz/SpotiFLAC/releases"))
except Exception as e:
print(f"Error checking for updates: {e}")
def load_settings(self): def load_settings(self):
headless = self.settings.value('headless', True, type=bool)
fallback = self.settings.value('fallback', False, type=bool) fallback = self.settings.value('fallback', False, type=bool)
service = self.settings.value('service', 'tidal') service = self.settings.value('service', 'amazon')
format_type = self.settings.value('format', 'title_artist') format_type = self.settings.value('format', 'title_artist')
output_dir = self.settings.value('output_dir', self.default_music_dir) output_dir = self.settings.value('output_dir', self.default_music_dir)
self.headless_checkbox.setChecked(headless)
self.fallback_checkbox.setChecked(fallback) self.fallback_checkbox.setChecked(fallback)
for i in range(self.service_combo.count()): for i in range(self.service_combo.count()):
if self.service_combo.itemData(i) == service: if self.service_combo.itemData(i, Qt.ItemDataRole.UserRole + 1) == service:
self.service_combo.setCurrentIndex(i) self.service_combo.setCurrentIndex(i)
break break
@@ -222,12 +379,10 @@ class SpotifyFlacGUI(QMainWindow):
self.dir_input.setText(output_dir) self.dir_input.setText(output_dir)
def setup_settings_persistence(self): def setup_settings_persistence(self):
self.headless_checkbox.stateChanged.connect(
lambda x: self.settings.setValue('headless', bool(x)))
self.fallback_checkbox.stateChanged.connect( self.fallback_checkbox.stateChanged.connect(
lambda x: self.settings.setValue('fallback', bool(x))) lambda x: self.settings.setValue('fallback', bool(x)))
self.service_combo.currentIndexChanged.connect( self.service_combo.currentIndexChanged.connect(
lambda i: self.settings.setValue('service', self.service_combo.itemData(i))) lambda i: self.settings.setValue('service', self.service_combo.itemData(i, Qt.ItemDataRole.UserRole + 1)))
self.format_title_artist.toggled.connect( self.format_title_artist.toggled.connect(
lambda x: self.settings.setValue('format', 'title_artist' if x else 'artist_title')) lambda x: self.settings.setValue('format', 'title_artist' if x else 'artist_title'))
self.dir_input.textChanged.connect( self.dir_input.textChanged.connect(
@@ -282,11 +437,6 @@ class SpotifyFlacGUI(QMainWindow):
settings_container_layout.setContentsMargins(0, 0, 0, 0) settings_container_layout.setContentsMargins(0, 0, 0, 0)
settings_container_layout.setSpacing(10) settings_container_layout.setSpacing(10)
self.headless_checkbox = QCheckBox("Headless")
self.headless_checkbox.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.headless_checkbox.setChecked(True)
settings_container_layout.addWidget(self.headless_checkbox)
self.fallback_checkbox = QCheckBox("Fallback") self.fallback_checkbox = QCheckBox("Fallback")
self.fallback_checkbox.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) self.fallback_checkbox.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.fallback_checkbox.setChecked(False) self.fallback_checkbox.setChecked(False)
@@ -312,8 +462,8 @@ class SpotifyFlacGUI(QMainWindow):
format_layout.setSpacing(10) format_layout.setSpacing(10)
format_label = QLabel("Filename:") format_label = QLabel("Filename:")
self.format_title_artist = QRadioButton("Title") self.format_title_artist = QRadioButton("Title - Artist")
self.format_artist_title = QRadioButton("Artist") self.format_artist_title = QRadioButton("Artist - Title")
self.format_title_artist.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) self.format_title_artist.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.format_artist_title.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) self.format_artist_title.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self.format_title_artist.setChecked(True) self.format_title_artist.setChecked(True)
@@ -426,10 +576,14 @@ class SpotifyFlacGUI(QMainWindow):
bottom_layout.addWidget(self.update_button) bottom_layout.addWidget(self.update_button)
self.main_layout.addLayout(bottom_layout) self.main_layout.addLayout(bottom_layout)
def save_url(self, url):
self.settings.setValue('last_url', url)
self.validate_url(url)
def open_update_page(self): def open_update_page(self):
import webbrowser import webbrowser
webbrowser.open('https://github.com/afkarxyz/SpotifyFLAC/releases') webbrowser.open('https://github.com/afkarxyz/SpotiFLAC/releases')
def validate_url(self, url): def validate_url(self, url):
url = url.strip() url = url.strip()
@@ -459,10 +613,9 @@ class SpotifyFlacGUI(QMainWindow):
return return
self.fetch_button.setEnabled(False) self.fetch_button.setEnabled(False)
self.status_label.setText("Fetching track information...") self.status_label.setText("Fetching track information...")
headless = self.headless_checkbox.isChecked()
fallback = self.fallback_checkbox.isChecked() fallback = self.fallback_checkbox.isChecked()
service = self.service_combo.currentData() service = self.service_combo.currentData()
self.fetcher = MetadataFetcher(url, headless=headless, service=service, use_fallback=fallback) self.fetcher = MetadataFetcher(url, service=service, use_fallback=fallback)
self.fetcher.finished.connect(self.handle_track_info) self.fetcher.finished.connect(self.handle_track_info)
self.fetcher.error.connect(self.handle_fetch_error) self.fetcher.error.connect(self.handle_fetch_error)
self.fetcher.start() self.fetcher.start()
@@ -471,25 +624,43 @@ class SpotifyFlacGUI(QMainWindow):
self.metadata = metadata self.metadata = metadata
self.fetch_button.setEnabled(True) self.fetch_button.setEnabled(True)
self.title_label.setText(metadata['title'].strip()) self.title_label.setText(metadata['title'].strip())
self.artist_label.setText(metadata['artists'].strip())
artist_text = ""
artists_list = metadata['artists'].strip().split(",")
if len(artists_list) > 1:
artist_text += "<b>Artists</b> " + metadata['artists'].strip()
else:
artist_text += "<b>Artist</b> " + metadata['artists'].strip()
if metadata.get('release_date'):
try:
date_obj = datetime.fromisoformat(metadata['release_date'].replace('Z', '+00:00'))
formatted_date = date_obj.strftime("%d-%m-%Y")
artist_text += f"<br><b>Released</b> {formatted_date}"
except:
if metadata['release_date']:
artist_text += f"<br><b>Released</b> {metadata['release_date']}"
if metadata.get('duration'):
duration_ms = metadata['duration']
minutes = int(duration_ms / 60000)
seconds = int((duration_ms % 60000) / 1000)
artist_text += f"<br><b>Duration</b> {minutes}:{seconds:02d}"
self.artist_label.setText(artist_text)
self.artist_label.setTextFormat(Qt.TextFormat.RichText)
self.image_downloader = ImageDownloader(metadata['cover']) self.image_downloader = ImageDownloader(metadata['cover'])
self.image_downloader.finished.connect(self.update_cover_art) self.image_downloader.finished.connect(self.update_cover_art)
self.image_downloader.start() self.image_downloader.start()
self.input_widget.hide() self.input_widget.hide()
self.track_widget.show() self.track_widget.show()
self.download_button.show() self.download_button.show()
self.cancel_button.show() self.cancel_button.show()
self.update_button.hide() self.update_button.hide()
self.status_label.clear() self.status_label.clear()
self.adjustWindowHeight()
def adjustWindowHeight(self):
title_height = self.title_label.sizeHint().height()
artist_height = self.artist_label.sizeHint().height()
base_height = 180
additional_height = max(0, (title_height + artist_height) - 40)
new_height = min(300, base_height + additional_height)
self.setFixedHeight(int(new_height))
def update_cover_art(self, image_data): def update_cover_art(self, image_data):
pixmap = QPixmap() pixmap = QPixmap()
@@ -530,6 +701,7 @@ class SpotifyFlacGUI(QMainWindow):
self.start_download() self.start_download()
def clear_form(self): def clear_form(self):
self.settings.setValue('last_url', '')
self.url_input.clear() self.url_input.clear()
self.progress_bar.hide() self.progress_bar.hide()
self.progress_bar.setValue(0) self.progress_bar.setValue(0)
@@ -601,7 +773,7 @@ class SpotifyFlacGUI(QMainWindow):
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
window = SpotifyFlacGUI() window = SpotiFlacGUI()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
+70 -10
View File
@@ -2,7 +2,6 @@ import requests
import time import time
import os import os
import asyncio import asyncio
from GetMetadata import main as get_metadata
class TrackDownloader: class TrackDownloader:
def __init__(self, use_fallback=False): def __init__(self, use_fallback=False):
@@ -14,6 +13,7 @@ class TrackDownloader:
self.filename_format = 'title_artist' self.filename_format = 'title_artist'
self.use_fallback = use_fallback self.use_fallback = use_fallback
self.base_domain = "lucida.su" if use_fallback else "lucida.to" self.base_domain = "lucida.su" if use_fallback else "lucida.to"
self.api_base = "https://apislucida.vercel.app"
def set_progress_callback(self, callback): def set_progress_callback(self, callback):
self.progress_callback = callback self.progress_callback = callback
@@ -28,9 +28,19 @@ class TrackDownloader:
filename = f"{metadata['title']} - {metadata['artists']}.flac" filename = f"{metadata['title']} - {metadata['artists']}.flac"
return self.sanitize_filename(filename) return self.sanitize_filename(filename)
async def get_track_info(self): async def get_track_info(self, track_id, service="amazon", use_fallback=None):
metadata = await get_metadata() if use_fallback is None:
return metadata use_fallback = self.use_fallback
fallback = "su" if use_fallback else "to"
api_url = f"{self.api_base}/{fallback}/{track_id}/{service}"
try:
response = requests.get(api_url)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise Exception(f"Failed to get track info: {str(e)}")
def sanitize_filename(self, filename): def sanitize_filename(self, filename):
invalid_chars = '<>:"/\\|?*' invalid_chars = '<>:"/\\|?*'
@@ -48,8 +58,8 @@ class TrackDownloader:
def download(self, metadata, output_dir): def download(self, metadata, output_dir):
track_url = metadata['url'] track_url = metadata['url']
primary_token = metadata['token'] primary_token = metadata['token']['primary']
expiry = metadata['expiry'] expiry = metadata['token']['expiry']
print(f"Starting download for: {track_url}") print(f"Starting download for: {track_url}")
@@ -91,10 +101,31 @@ class TrackDownloader:
print("Waiting for track processing to complete") print("Waiting for track processing to complete")
while True: while True:
completion_response = self.client.get(completion_url, headers=self.headers).json() completion_response = self.client.get(completion_url, headers=self.headers).json()
if completion_response["status"] == "completed":
status = completion_response["status"]
if status == "completed":
print("Processing completed: 100%")
break break
elif completion_response["status"] == "error": elif status == "error":
raise Exception(f"API request failed: {completion_response.get('message', 'Unknown error')}") raise Exception(f"API request failed: {completion_response.get('message', 'Unknown error')}")
else:
progress = completion_response.get("progress", {})
if progress:
current = progress.get("current", 0)
total = progress.get("total", 100)
percent = int((current / total) * 100) if total > 0 else 0
action = progress.get("action", "Processing")
print(f"Progress: {percent}% - {action} ({current}/{total})")
if action.lower() == "metadata":
if self.progress_callback:
self.progress_callback(0, 0)
else:
print(f"Status: {status} - Waiting for progress information...")
if status.lower() == "metadata":
if self.progress_callback:
self.progress_callback(0, 0)
time.sleep(1) time.sleep(1)
download_url = f"https://{server}.{self.base_domain}/api/fetch/request/{handoff}/download" download_url = f"https://{server}.{self.base_domain}/api/fetch/request/{handoff}/download"
@@ -108,16 +139,33 @@ class TrackDownloader:
try: try:
with open(file_path, 'wb') as file: with open(file_path, 'wb') as file:
start_time = time.time()
last_update_time = start_time
for chunk in response.iter_content(chunk_size=8192): for chunk in response.iter_content(chunk_size=8192):
if chunk: if chunk:
file.write(chunk) file.write(chunk)
downloaded_size += len(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"Download progress: {progress_percent:.2f}% ({downloaded_size}/{total_size}) - {speed:.2f} MB/s")
else:
print(f"Downloaded {downloaded_size / (1024 * 1024):.2f} MB")
last_update_time = current_time
if self.progress_callback: if self.progress_callback:
self.progress_callback(downloaded_size, total_size) self.progress_callback(downloaded_size, total_size)
if downloaded_size == 0: if downloaded_size == 0:
raise Exception("No data received from server") raise Exception("No data received from server")
print(f"Download completed: {file_path}")
return file_path return file_path
except Exception as e: except Exception as e:
@@ -131,11 +179,23 @@ class TrackDownloader:
async def main(): async def main():
downloader = TrackDownloader() downloader = TrackDownloader()
output_dir = "." output_dir = "."
track_id = "2plbrEY59IikOBgBGLjaoe"
service = "amazon"
def progress_update(current, total):
if total > 0:
percent = (current / total) * 100
print(f"\rDownload progress: {percent:.2f}% ({current}/{total})", end="")
downloader.set_progress_callback(progress_update)
try: try:
metadata = await downloader.get_track_info() print(f"Getting track info for ID: {track_id} from {service}")
metadata = await downloader.get_track_info(track_id, service)
print(f"Track info received: {metadata['title']} by {metadata['artists']}")
downloaded_file = downloader.download(metadata, output_dir) downloaded_file = downloader.download(metadata, output_dir)
print(f"File downloaded successfully: {downloaded_file}") print(f"\nFile downloaded successfully: {downloaded_file}")
except Exception as e: except Exception as e:
print(f"An error occurred: {str(e)}") print(f"An error occurred: {str(e)}")
+3
View File
@@ -0,0 +1,3 @@
{
"version": "2.0"
}