Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25c5a4d175 | |||
| 33a6137f75 | |||
| b4fcb6bca6 | |||
| 5ab19a6d37 | |||
| 8547e6d410 | |||
| 17666d8027 | |||
| ab208482ca | |||
| 76e02d77e8 | |||
| 75cc4543ad | |||
| 0b468c4b60 | |||
| 87a6a778f7 | |||
| ef893ab9f4 | |||
| 3eda3245ca | |||
| f6f238361c | |||
| 998730bbb3 | |||
| 56a1d29d78 | |||
| 4b7316636e | |||
| 55669ec45f | |||
| 3304b13828 | |||
| 579bb1415a | |||
| f0e71261a5 | |||
| e2ad51da34 | |||
| 9e403ab1ba | |||
| 7058559ddc | |||
| 861f303a4f | |||
| 9a28e8bd94 | |||
| f75385c4e8 | |||
| 2eac274ee0 | |||
| 49a8de1b35 | |||
| cd2500d1df | |||
| ea1372f1fe | |||
| 65fbb9a8e9 | |||
| de16d9e25d | |||
| 6dd19b563b | |||
| 303b76d1ec | |||
| dbcd49225d |
@@ -3,18 +3,20 @@
|
||||

|
||||
|
||||
<div align="center">
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal & Deezer.
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Tidal & Deezer.
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.1/SpotiFLAC.exe)
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v5.1/SpotiFLAC.exe)
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
## Lossless Audio Check
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
from mutagen.flac import FLAC
|
||||
from random import randrange
|
||||
|
||||
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)}"
|
||||
|
||||
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'
|
||||
'User-Agent': get_random_user_agent()
|
||||
})
|
||||
self.progress_callback = None
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-eu" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<g id="eu-d">
|
||||
<g id="eu-b">
|
||||
<path id="eu-a" d="m0-1-.3 1 .5.1z"/>
|
||||
<use xlink:href="#eu-a" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
<g id="eu-c">
|
||||
<use xlink:href="#eu-b" transform="rotate(72)"/>
|
||||
<use xlink:href="#eu-b" transform="rotate(144)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-c" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<path fill="#039" d="M0 0h640v480H0z"/>
|
||||
<g fill="#fc0" transform="translate(320 242.3)scale(23.7037)">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="6"/>
|
||||
<g id="eu-e">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" x="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(-144 -2.3 -2.1)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(144 -2.1 -2.3)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -4.7 -2)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -5 .5)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-e" width="100%" height="100%" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,5 +1,6 @@
|
||||
from time import sleep
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from pathlib import Path
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
@@ -12,20 +13,34 @@ from typing import Dict, Any, List, Tuple
|
||||
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)}"
|
||||
|
||||
# https://github.com/xyloflake/spot-secrets-go
|
||||
def generate_totp():
|
||||
url = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretBytes.json"
|
||||
local_path = Path.home() / ".spotify-secret" / "secretBytes.json"
|
||||
used_local = False
|
||||
|
||||
try:
|
||||
url = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json"
|
||||
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}")
|
||||
raise Exception(f"GitHub fetch failed with status: {resp.status_code}")
|
||||
secrets_list = resp.json()
|
||||
|
||||
except Exception as github_error:
|
||||
try:
|
||||
if local_path.exists():
|
||||
with open(local_path, 'r') as f:
|
||||
secrets_list = json.load(f)
|
||||
used_local = True
|
||||
else:
|
||||
raise Exception(f"GitHub failed ({github_error}) and no local file found at {local_path}")
|
||||
except Exception as local_error:
|
||||
raise Exception(f"Failed to fetch secrets from both GitHub and local: {local_error}")
|
||||
|
||||
try:
|
||||
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)}")
|
||||
raise Exception(f"Failed to process secrets: {str(e)}")
|
||||
|
||||
processed = [byte ^ ((i % 33) + 9) for i, byte in enumerate(secret_cipher)]
|
||||
processed_str = "".join(map(str, processed))
|
||||
@@ -57,8 +72,10 @@ 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/{}'
|
||||
artist_base_url = 'https://api.spotify.com/v1/artists/{}'
|
||||
artist_albums_url = 'https://api.spotify.com/v1/artists/{}/albums'
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'User-Agent': get_random_user_agent(),
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
@@ -97,11 +114,22 @@ def parse_uri(uri):
|
||||
if parts[1] == "embed":
|
||||
parts = parts[1:]
|
||||
|
||||
if len(parts) > 1 and parts[1].startswith("intl-"):
|
||||
parts = parts[1:]
|
||||
|
||||
l = len(parts)
|
||||
if l == 3 and parts[1] in ["album", "track", "playlist"]:
|
||||
if l == 3 and parts[1] in ["album", "track", "playlist", "artist"]:
|
||||
return {"type": parts[1], "id": parts[2]}
|
||||
if l == 5 and parts[3] == "playlist":
|
||||
return {"type": parts[3], "id": parts[4]}
|
||||
if l >= 4 and parts[1] == "artist" and len(parts) >= 4:
|
||||
if parts[3] == "discography":
|
||||
discography_type = "all"
|
||||
if len(parts) >= 5 and parts[4] in ["all", "album", "single", "compilation"]:
|
||||
discography_type = parts[4]
|
||||
return {"type": "artist_discography", "id": parts[2], "discography_type": discography_type}
|
||||
else:
|
||||
return {"type": "artist", "id": parts[2]}
|
||||
|
||||
raise SpotifyInvalidUrlException("ERROR: unable to determine Spotify URL type or type is unsupported.")
|
||||
|
||||
@@ -336,6 +364,69 @@ def get_raw_spotify_data(spotify_url, batch: bool = False, delay: float = 1.0):
|
||||
raw_data = track_data
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get track data: {str(e)}"}
|
||||
|
||||
elif url_info["type"] == "artist_discography":
|
||||
try:
|
||||
artist_data = get_json_from_api(
|
||||
artist_base_url.format(url_info["id"]),
|
||||
access_token
|
||||
)
|
||||
if not artist_data:
|
||||
return {"error": "Failed to get artist data"}
|
||||
|
||||
discography_type = url_info.get("discography_type", "all")
|
||||
if discography_type == "all":
|
||||
include_groups = "album,single,compilation"
|
||||
else:
|
||||
include_groups = discography_type
|
||||
|
||||
albums = []
|
||||
albums_url = f'{artist_albums_url.format(url_info["id"])}?include_groups={include_groups}&limit=50'
|
||||
|
||||
if batch:
|
||||
albums, num_batches = fetch_tracks_in_batches(albums_url, access_token, 50, delay)
|
||||
raw_data = {
|
||||
"artist_info": artist_data,
|
||||
"albums": albums,
|
||||
"discography_type": discography_type,
|
||||
"_batch_count": num_batches,
|
||||
"_batch_enabled": True
|
||||
}
|
||||
else:
|
||||
while albums_url:
|
||||
album_data = get_json_from_api(albums_url, access_token)
|
||||
if not album_data:
|
||||
break
|
||||
|
||||
albums.extend(album_data['items'])
|
||||
albums_url = album_data.get('next')
|
||||
if albums_url and "&locale=" in albums_url:
|
||||
albums_url = albums_url.split("&locale=")[0]
|
||||
|
||||
raw_data = {
|
||||
"artist_info": artist_data,
|
||||
"albums": albums,
|
||||
"discography_type": discography_type,
|
||||
"_batch_enabled": False
|
||||
}
|
||||
|
||||
raw_data['_token'] = access_token
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get artist discography data: {str(e)}"}
|
||||
|
||||
elif url_info["type"] == "artist":
|
||||
try:
|
||||
artist_data = get_json_from_api(
|
||||
artist_base_url.format(url_info["id"]),
|
||||
access_token
|
||||
)
|
||||
if not artist_data:
|
||||
return {"error": "Failed to get artist data"}
|
||||
|
||||
raw_data = artist_data
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to get artist data: {str(e)}"}
|
||||
|
||||
return raw_data
|
||||
|
||||
@@ -466,6 +557,134 @@ def format_playlist_data(playlist_data):
|
||||
"track_list": track_list
|
||||
}
|
||||
|
||||
def format_artist_discography_data(discography_data):
|
||||
artist_info = discography_data.get('artist_info', {})
|
||||
albums = discography_data.get('albums', [])
|
||||
access_token = discography_data.get('_token', '')
|
||||
|
||||
artist_image = ''
|
||||
if artist_info.get('images'):
|
||||
artist_image = artist_info.get('images', [{}])[0].get('url', '')
|
||||
|
||||
formatted_artist_info = {
|
||||
"name": artist_info.get('name', ''),
|
||||
"followers": artist_info.get('followers', {}).get('total', 0),
|
||||
"genres": artist_info.get('genres', []),
|
||||
"images": artist_image,
|
||||
"external_urls": artist_info.get('external_urls', {}).get('spotify', ''),
|
||||
"discography_type": discography_data.get('discography_type', 'all'),
|
||||
"total_albums": len(albums)
|
||||
}
|
||||
|
||||
if discography_data.get('_batch_enabled', False):
|
||||
formatted_artist_info["batch"] = f"{discography_data.get('_batch_count', 1)}"
|
||||
|
||||
album_list = []
|
||||
all_tracks = []
|
||||
|
||||
for album in albums:
|
||||
album_image = ''
|
||||
if album.get('images'):
|
||||
album_image = album.get('images', [{}])[0].get('url', '')
|
||||
|
||||
album_artists = []
|
||||
for artist in album.get('artists', []):
|
||||
album_artists.append(artist['name'])
|
||||
|
||||
album_info = {
|
||||
"id": album.get('id', ''),
|
||||
"name": album.get('name', ''),
|
||||
"album_type": album.get('album_type', ''),
|
||||
"release_date": album.get('release_date', ''),
|
||||
"total_tracks": album.get('total_tracks', 0),
|
||||
"artists": ", ".join(album_artists),
|
||||
"images": album_image,
|
||||
"external_urls": album.get('external_urls', {}).get('spotify', '')
|
||||
}
|
||||
|
||||
album_list.append(album_info)
|
||||
|
||||
if access_token and album.get('id'):
|
||||
try:
|
||||
album_tracks_data = get_json_from_api(
|
||||
f'{album_base_url.format(album.get("id"))}/tracks?limit=50',
|
||||
access_token
|
||||
)
|
||||
|
||||
if album_tracks_data:
|
||||
tracks = []
|
||||
tracks_url = f'{album_base_url.format(album.get("id"))}/tracks?limit=50'
|
||||
|
||||
while tracks_url:
|
||||
track_data = get_json_from_api(tracks_url, access_token)
|
||||
if not track_data:
|
||||
break
|
||||
|
||||
tracks.extend(track_data['items'])
|
||||
tracks_url = track_data.get('next')
|
||||
if tracks_url and "&locale=" in tracks_url:
|
||||
tracks_url = tracks_url.split("&locale=")[0]
|
||||
|
||||
for track in tracks:
|
||||
track_artists = []
|
||||
for artist in track.get('artists', []):
|
||||
track_artists.append(artist['name'])
|
||||
|
||||
track_id = track.get('id', '')
|
||||
track_isrc = ''
|
||||
|
||||
if track_id:
|
||||
try:
|
||||
full_track_data = get_json_from_api(
|
||||
track_base_url.format(track_id),
|
||||
access_token
|
||||
)
|
||||
if full_track_data:
|
||||
track_isrc = full_track_data.get('external_ids', {}).get('isrc', '')
|
||||
except:
|
||||
pass
|
||||
|
||||
formatted_track = {
|
||||
"artists": ", ".join(track_artists),
|
||||
"name": track.get('name', ''),
|
||||
"album_name": album.get('name', ''),
|
||||
"album_type": album.get('album_type', ''),
|
||||
"duration_ms": track.get('duration_ms', 0),
|
||||
"images": album_image,
|
||||
"release_date": album.get('release_date', ''),
|
||||
"track_number": track.get('track_number', 0),
|
||||
"external_urls": track.get('external_urls', {}).get('spotify', ''),
|
||||
"isrc": track_isrc
|
||||
}
|
||||
|
||||
all_tracks.append(formatted_track)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting tracks for album {album.get('name', '')}: {str(e)}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"artist_info": formatted_artist_info,
|
||||
"album_list": album_list,
|
||||
"track_list": all_tracks
|
||||
}
|
||||
|
||||
def format_artist_data(artist_data):
|
||||
artist_image = ''
|
||||
if artist_data.get('images'):
|
||||
artist_image = artist_data.get('images', [{}])[0].get('url', '')
|
||||
|
||||
return {
|
||||
"artist": {
|
||||
"name": artist_data.get('name', ''),
|
||||
"followers": artist_data.get('followers', {}).get('total', 0),
|
||||
"genres": artist_data.get('genres', []),
|
||||
"images": artist_image,
|
||||
"external_urls": artist_data.get('external_urls', {}).get('spotify', ''),
|
||||
"popularity": artist_data.get('popularity', 0)
|
||||
}
|
||||
}
|
||||
|
||||
def process_spotify_data(raw_data, data_type):
|
||||
if not raw_data or "error" in raw_data:
|
||||
return {"error": "Invalid data provided"}
|
||||
@@ -477,6 +696,10 @@ def process_spotify_data(raw_data, data_type):
|
||||
return format_album_data(raw_data)
|
||||
elif data_type == "playlist":
|
||||
return format_playlist_data(raw_data)
|
||||
elif data_type == "artist_discography":
|
||||
return format_artist_discography_data(raw_data)
|
||||
elif data_type == "artist":
|
||||
return format_artist_data(raw_data)
|
||||
else:
|
||||
return {"error": "Invalid data type"}
|
||||
except Exception as e:
|
||||
@@ -495,11 +718,23 @@ if __name__ == '__main__':
|
||||
album = "https://open.spotify.com/album/6J84szYCnMfzEcvIcfWMFL"
|
||||
song = "https://open.spotify.com/track/7so0lgd0zP2Sbgs2d7a1SZ"
|
||||
|
||||
artist_discography_all = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/all"
|
||||
artist_discography_albums = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/album"
|
||||
artist_discography_singles = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/single"
|
||||
artist_discography_compilations = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/compilation"
|
||||
|
||||
print("=== Testing Artist Discography (All) ===")
|
||||
filtered_discography = get_filtered_data(artist_discography_all, batch=True, delay=0.1)
|
||||
print(json.dumps(filtered_discography, indent=2))
|
||||
|
||||
print("\n=== Testing Playlist ===")
|
||||
filtered_playlist = get_filtered_data(playlist, batch=True, delay=0.1)
|
||||
print(json.dumps(filtered_playlist, indent=2))
|
||||
|
||||
print("\n=== Testing Album ===")
|
||||
filtered_album = get_filtered_data(album)
|
||||
print(json.dumps(filtered_album, indent=2))
|
||||
|
||||
print("\n=== Testing Track ===")
|
||||
filtered_track = get_filtered_data(song)
|
||||
print(json.dumps(filtered_track, indent=2))
|
||||
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from DrissionPage import ChromiumPage, ChromiumOptions
|
||||
|
||||
def summarise(caps):
|
||||
real = {}
|
||||
for cap in caps:
|
||||
sec = cap.get("secret")
|
||||
if not sec or not isinstance(sec, str):
|
||||
continue
|
||||
ver = cap.get("version") or cap.get("obj", {}).get("version")
|
||||
if ver and ver != 0:
|
||||
real[str(int(ver))] = sec
|
||||
|
||||
if not real:
|
||||
return False, "No secrets found."
|
||||
|
||||
versions = sorted(int(k) for k in real.keys())
|
||||
secret_bytes = [
|
||||
{"version": v, "secret": [ord(c) for c in real[str(v)]]}
|
||||
for v in versions
|
||||
]
|
||||
|
||||
secrets_dir = Path.home() / ".spotify-secret"
|
||||
secrets_dir.mkdir(exist_ok=True)
|
||||
|
||||
output_file = secrets_dir / "secretBytes.json"
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(secret_bytes, f, indent=2)
|
||||
|
||||
return True, f"Saved to: {output_file}"
|
||||
|
||||
def grab_live(progress_callback=None):
|
||||
def emit_progress(msg):
|
||||
if progress_callback:
|
||||
progress_callback(msg)
|
||||
else:
|
||||
print(msg)
|
||||
|
||||
stealth = """(()=>{
|
||||
Object.defineProperty(navigator,'webdriver',{get:()=>false});
|
||||
Object.defineProperty(navigator,'languages',{get:()=>['en-US','en']});
|
||||
Object.defineProperty(navigator,'plugins',{get:()=>[1,2,3,4,5]});
|
||||
window.chrome={runtime:{}};
|
||||
const q=navigator.permissions.query;
|
||||
navigator.permissions.query=p=>p.name==='notifications'?Promise.resolve({state:Notification.permission}):q(p);
|
||||
const g=WebGLRenderingContext.prototype.getParameter;
|
||||
WebGLRenderingContext.prototype.getParameter=function(p){
|
||||
if(p===37445)return'Intel Inc.';if(p===37446)return'Intel Iris OpenGL Engine';return g.call(this,p);
|
||||
};
|
||||
})();"""
|
||||
|
||||
hook = """(()=>{if(globalThis.__secretHookInstalled)return;
|
||||
globalThis.__secretHookInstalled=true;globalThis.__captures=[];
|
||||
Object.defineProperty(Object.prototype,'secret',{configurable:true,set:function(v){
|
||||
try{__captures.push({secret:v,version:this.version,obj:this});}catch(e){}
|
||||
Object.defineProperty(this,'secret',{value:v,writable:true,configurable:true,enumerable:true});}});
|
||||
})();"""
|
||||
|
||||
co = ChromiumOptions()
|
||||
co.headless(True)
|
||||
co.set_argument('--disable-blink-features=AutomationControlled')
|
||||
co.set_argument('--no-sandbox')
|
||||
|
||||
page = ChromiumPage(addr_or_opts=co)
|
||||
try:
|
||||
page.run_cdp('Page.addScriptToEvaluateOnNewDocument', source=stealth)
|
||||
page.run_cdp('Page.addScriptToEvaluateOnNewDocument', source=hook)
|
||||
emit_progress("Opening Spotify...")
|
||||
page.get("https://open.spotify.com")
|
||||
time.sleep(3)
|
||||
caps = page.run_js("return globalThis.__captures || []")
|
||||
for c in caps:
|
||||
if isinstance(c, dict) and c.get("secret") and c.get("version"):
|
||||
emit_progress(f"Secret({int(c['version'])}): {c['secret']}")
|
||||
return caps or []
|
||||
finally:
|
||||
page.quit()
|
||||
|
||||
def scrape_and_save(progress_callback=None):
|
||||
try:
|
||||
caps = grab_live(progress_callback)
|
||||
return summarise(caps)
|
||||
except Exception as e:
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
def main():
|
||||
success, message = scrape_and_save()
|
||||
print(message)
|
||||
return 0 if success else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 304 B |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 402 B |
@@ -1,251 +0,0 @@
|
||||
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:
|
||||
response = self.session.get(download_url, timeout=900)
|
||||
response.raise_for_status()
|
||||
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
while is_paused_callback and is_paused_callback():
|
||||
time.sleep(0.1)
|
||||
if is_stopped_callback and is_stopped_callback():
|
||||
raise Exception("Download stopped")
|
||||
|
||||
with open(temp_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
downloaded_size = len(response.content)
|
||||
total_size = downloaded_size
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,9 @@
|
||||
PyQt6
|
||||
pyqt6-tools
|
||||
pyqtdarktheme
|
||||
requests
|
||||
mutagen
|
||||
pyotp
|
||||
packaging
|
||||
pyinstaller
|
||||
DrissionPage
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import base64
|
||||
import requests
|
||||
import json
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import PictureType
|
||||
|
||||
@@ -16,19 +16,88 @@ class ProgressCallback:
|
||||
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
||||
|
||||
class TidalDownloader:
|
||||
def __init__(self, timeout=30, max_retries=3):
|
||||
def __init__(self, timeout=30, max_retries=3, api_url=None):
|
||||
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="
|
||||
self.client_id = base64.b64decode("NkJEU1JkcEs5aHFFQlRnVQ==").decode()
|
||||
self.client_secret = base64.b64decode("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=").decode()
|
||||
self.api_url = api_url or "https://hifi.401658.xyz"
|
||||
|
||||
@staticmethod
|
||||
def get_available_apis():
|
||||
try:
|
||||
response = requests.get("https://status.monochrome.tf/api/stream", timeout=10, stream=True)
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode('utf-8')
|
||||
if line_str.startswith('data: '):
|
||||
data = json.loads(line_str[6:])
|
||||
|
||||
api_instances = [
|
||||
inst for inst in data.get('instances', [])
|
||||
if inst.get('instance_type') == 'api' and inst.get('last_check', {}).get('success')
|
||||
]
|
||||
|
||||
api_instances.sort(key=lambda x: x.get('avg_response_time', 9999))
|
||||
|
||||
return api_instances
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch API list: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def select_api_interactive():
|
||||
apis = TidalDownloader.get_available_apis()
|
||||
|
||||
if not apis:
|
||||
print("No APIs available, using default: https://hifi.401658.xyz")
|
||||
return "https://hifi.401658.xyz"
|
||||
|
||||
print("\n=== Available Tidal APIs ===")
|
||||
print(f"{'No':<4} {'URL':<40} {'Status':<8} {'Uptime':<8} {'Avg Response':<12}")
|
||||
print("-" * 80)
|
||||
|
||||
for i, api in enumerate(apis, 1):
|
||||
url = api.get('url', 'N/A')
|
||||
status = "UP" if api.get('last_check', {}).get('success') else "DOWN"
|
||||
uptime = f"{api.get('uptime', 0):.1f}%"
|
||||
avg_time = f"{api.get('avg_response_time', 0)}ms"
|
||||
|
||||
print(f"{i:<4} {url:<40} {status:<8} {uptime:<8} {avg_time:<12}")
|
||||
|
||||
print("\n0 Use default (https://hifi.401658.xyz)")
|
||||
print("-" * 80)
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input(f"\nSelect API (0-{len(apis)}) [1 for fastest]: ").strip()
|
||||
|
||||
if not choice:
|
||||
choice = "1"
|
||||
|
||||
choice_num = int(choice)
|
||||
|
||||
if choice_num == 0:
|
||||
return "https://hifi.401658.xyz"
|
||||
elif 1 <= choice_num <= len(apis):
|
||||
selected_url = apis[choice_num - 1]['url']
|
||||
print(f"\nSelected: {selected_url}")
|
||||
return selected_url
|
||||
else:
|
||||
print(f"Invalid choice. Please enter 0-{len(apis)}")
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number.")
|
||||
except KeyboardInterrupt:
|
||||
print("\nUsing default API")
|
||||
return "https://hifi.401658.xyz"
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
|
||||
|
||||
def sanitize_filename(self, filename):
|
||||
if not filename:
|
||||
return "Unknown Track"
|
||||
@@ -144,7 +213,7 @@ class TidalDownloader:
|
||||
|
||||
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}"
|
||||
download_api_url = f"{self.api_url}/track/?id={track_id}&quality={quality}"
|
||||
|
||||
try:
|
||||
response = requests.get(download_api_url, timeout=self.timeout)
|
||||
@@ -316,13 +385,46 @@ class TidalDownloader:
|
||||
print(f"Error embedding metadata: {str(e)}")
|
||||
return False
|
||||
|
||||
def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None):
|
||||
def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None, auto_fallback=False):
|
||||
if output_dir != ".":
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise Exception(f"Directory error: {e}")
|
||||
|
||||
|
||||
if auto_fallback:
|
||||
apis = self.get_available_apis()
|
||||
if not apis:
|
||||
print("No APIs available for fallback, using current API")
|
||||
return self._download_single(query, isrc, output_dir, quality, is_paused_callback, is_stopped_callback)
|
||||
|
||||
last_error = None
|
||||
for i, api in enumerate(apis, 1):
|
||||
api_url = api.get('url')
|
||||
try:
|
||||
print(f"[Auto Fallback {i}/{len(apis)}] Trying: {api_url}")
|
||||
|
||||
fallback_downloader = TidalDownloader(api_url=api_url)
|
||||
fallback_downloader.set_progress_callback(self.progress_callback)
|
||||
|
||||
result = fallback_downloader._download_single(
|
||||
query, isrc, output_dir, quality,
|
||||
is_paused_callback, is_stopped_callback
|
||||
)
|
||||
|
||||
print(f"✓ Success with: {api_url}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
print(f"✗ Failed with {api_url}: {last_error[:80]}")
|
||||
continue
|
||||
|
||||
raise Exception(f"All {len(apis)} APIs failed. Last error: {last_error}")
|
||||
|
||||
return self._download_single(query, isrc, output_dir, quality, is_paused_callback, is_stopped_callback)
|
||||
|
||||
def _download_single(self, query, isrc, output_dir, quality, is_paused_callback, is_stopped_callback):
|
||||
track_info = self.get_track_info(query, isrc)
|
||||
track_id = track_info.get("id")
|
||||
|
||||
@@ -373,7 +475,9 @@ class TidalDownloader:
|
||||
|
||||
def main():
|
||||
print("=== TidalDL - Tidal Downloader ===")
|
||||
downloader = TidalDownloader(timeout=30, max_retries=3)
|
||||
|
||||
selected_api = TidalDownloader.select_api_interactive()
|
||||
downloader = TidalDownloader(timeout=30, max_retries=3, api_url=selected_api)
|
||||
|
||||
query = "APT."
|
||||
isrc = "USAT22409172"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-us" viewBox="0 0 640 480">
|
||||
<path fill="#bd3d44" d="M0 0h640v480H0"/>
|
||||
<path stroke="#fff" stroke-width="37" d="M0 55.3h640M0 129h640M0 203h640M0 277h640M0 351h640M0 425h640"/>
|
||||
<path fill="#192f5d" d="M0 0h364.8v258.5H0"/>
|
||||
<marker id="us-a" markerHeight="30" markerWidth="30">
|
||||
<path fill="#fff" d="m14 0 9 27L0 10h28L5 27z"/>
|
||||
</marker>
|
||||
<path fill="none" marker-mid="url(#us-a)" d="m0 0 16 11h61 61 61 61 60L47 37h61 61 60 61L16 63h61 61 61 61 60L47 89h61 61 60 61L16 115h61 61 61 61 60L47 141h61 61 60 61L16 166h61 61 61 61 60L47 192h61 61 60 61L16 218h61 61 61 61 60z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 648 B |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "4.1"
|
||||
"version": "5.1"
|
||||
}
|
||||
|
||||