qobuz-dl-remote/qobuz-dl-remote.py
2025-08-09 23:18:56 +02:00

181 lines
5.7 KiB
Python
Executable file

#!/usr/bin/env python3
from typing import Any, Literal
import requests
import tqdm
from mutagen.flac import FLAC, Picture
import sys
from dataclasses import dataclass
import datetime
from PIL import Image
from io import BytesIO
QOBUZ_API_BASE = "https://us.qobuz.squid.wtf/api"
QUALITY = 27
@dataclass
class Track:
title: str
artist: str
nr: int
id: str
album_name: str
release_date: datetime.date
cover: Image.Image | None
@dataclass
class ImageLink:
small: str | None
thumbnail: str | None
large: str | None
back: str | None
@classmethod
def from_dict(
cls,
data: dict[
Literal["small"]
| Literal["thumbnail"]
| Literal["large"]
| Literal["back"],
str | None,
],
) -> "ImageLink":
return ImageLink(**data)
@dataclass
class Album:
artist: str
title: str
tracks: list[Track] | None
maximum_bit_depth: int
maximum_sampling_rate: int
id: str
image: ImageLink
release_date: datetime.date
@classmethod
def from_dict(cls, data: dict[Any, Any]) -> "Album":
tracks = None
release_date = datetime.date.fromtimestamp(data["released_at"])
image = ImageLink.from_dict(data["image"])
image_data = None
if image.large is not None:
# Fetch image
print(f"Fetching image from {image.large}")
image_content = BytesIO(requests.get(image.large).content)
image_data = Image.open(image_content)
if "tracks" in data and "track_ids" in data:
tracks = [
Track(
nr=tnr + 1,
artist=data["artist"]["name"],
id=tid,
title=track["title"],
album_name=data["title"],
release_date=release_date,
cover=image_data,
)
for tnr, (tid, track) in enumerate(
zip(data["track_ids"], data["tracks"]["items"])
)
]
return cls(
artist=data["artist"]["name"],
title=data["title"],
tracks=tracks,
maximum_bit_depth=data["maximum_bit_depth"],
maximum_sampling_rate=data["maximum_sampling_rate"],
id=data["id"],
image=ImageLink.from_dict(data["image"]),
release_date=release_date,
)
class Qobuz:
def __init__(self, api_base=QOBUZ_API_BASE, quality: int = QUALITY) -> None:
self.api_base = api_base
self.quality = quality
def get_download_url(self, track_id: str) -> str:
url = f"{QOBUZ_API_BASE}/download-music"
result = requests.get(
url, params={"track_id": track_id, "quality": self.quality}
)
if result.status_code == 200:
result_json = result.json()
if result_json["success"]:
return result_json["data"]["url"]
raise RuntimeError(f"Failed to get download URL, result: {result}")
def download_track(self, track: Track, filename: str) -> None:
download_url = self.get_download_url(track.id)
response = requests.get(download_url, stream=True)
if response.status_code == 200:
total_size = int(response.headers.get("content-length", 0))
with tqdm.tqdm(
total=total_size, unit="B", unit_scale=True, desc=filename
) as pbar:
with open(filename, "wb") as file:
for chunk in response.iter_content(chunk_size=8192):
file.write(chunk)
pbar.update(len(chunk))
# Set metadata using mutagen
audio = FLAC(filename)
audio["title"] = track.title
audio["artist"] = track.artist
audio["tracknumber"] = str(track.nr)
audio["album"] = track.album_name
audio["date"] = track.release_date.isoformat()
audio.save()
print(f"Downloaded {filename}")
else:
raise RuntimeError("Failed to download track")
def get_album(self, album_id: str) -> Album:
url = f"{self.api_base}/get-album"
result = requests.get(url, params={"album_id": album_id})
if result.status_code == 200:
data = result.json()["data"]
return Album.from_dict(data)
raise RuntimeError(f"Failed to get album, result: {result}")
def download_album(self, album: Album) -> None:
artist = album.artist
album_title = album.title
tracks = album.tracks if album.tracks is not None else []
print(f"Downloading album: {artist} - {album_title}")
for nr, track in enumerate(tracks):
print(f"Downloading track #{nr + 1:02d} {track.title}...")
filename = f"{artist} - {album_title} - {nr + 1:02d} {track.title}.flac"
self.download_track(track, filename)
def search_album(self, query: str) -> Album:
print(f'Searching for "{query}"')
url = f"{self.api_base}/get-music"
result = requests.get(url, params={"q": query, "offset": 0})
if result.status_code == 200:
first_album_id = result.json()["data"]["albums"]["items"][0]["id"]
return self.get_album(first_album_id)
raise RuntimeError("No results")
def search_and_download(self, query: str) -> None:
album = self.search_album(query)
self.download_album(album)
def main() -> None:
# trackid = sys.argv[1]
# filename = sys.argv[2]
query = sys.argv[1]
qb = Qobuz()
print(qb.search_and_download(query))
if __name__ == "__main__":
main()