Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 286 additions & 93 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "streamrip"
version = "2.0.5"
version = "2.1.1"
description = "A fast, all-in-one music ripper for Qobuz, Deezer, Tidal, and SoundCloud"
authors = ["nathom <[email protected]>"]
license = "GPL-3.0-only"
Expand Down
48 changes: 46 additions & 2 deletions streamrip/client/deezer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import binascii
import hashlib
import logging
import os

import deezer
from Cryptodome.Cipher import AES
Expand Down Expand Up @@ -70,7 +71,20 @@ async def get_track(self, item_id: str) -> dict:
except Exception as e:
raise NonStreamableError(e)

items = await self.get_alternatives([item])
item = items[0]

if "readable" in item and not item["readable"]:
raise NonStreamableError(f"Track {item_id} not readable")

# User uploaded files might not have an album attached, because it does not exist in Deezer
if "album" not in item:
return item

album_id = item["album"]["id"]
if album_id == 0:
# todo: what to return here?
return item
try:
album_metadata, album_tracks = await asyncio.gather(
asyncio.to_thread(self.client.api.get_album, album_id),
Expand All @@ -91,16 +105,46 @@ async def get_album(self, item_id: str) -> dict:
asyncio.to_thread(self.client.api.get_album, item_id),
asyncio.to_thread(self.client.api.get_album_tracks, item_id),
)
album_metadata["tracks"] = album_tracks["data"]
album_metadata["tracks"] = await self.get_alternatives(album_tracks["data"])
album_metadata["track_total"] = len(album_tracks["data"])
return album_metadata

async def get_alternatives(self, tracks):
for track in tracks:
if not track["readable"]:
# We need to use gw instead of api
# gw has the fallback of a track, the api strangely enough, not
try:
retries = 0
while True:
retries += 1
if retries > 4:
break

item_fallbacks = await asyncio.to_thread(
self.client.gw.get_track_with_fallback, track["id"]
)
if "FALLBACK" in item_fallbacks:
track["id"] = item_fallbacks["FALLBACK"]["SNG_ID"]
track["readable"] = (
item_fallbacks["FALLBACK"]["STATUS"] == 1
)
else:
break
except Exception as e:
logger.error(
f"Error while getting fallbacks for track {track['id']}: {e}"
)

return tracks

async def get_playlist(self, item_id: str) -> dict:
pl_metadata, pl_tracks = await asyncio.gather(
asyncio.to_thread(self.client.api.get_playlist, item_id),
asyncio.to_thread(self.client.api.get_playlist_tracks, item_id),
)
pl_metadata["tracks"] = pl_tracks["data"]
pl_metadata["tracks"] = await self.get_alternatives(pl_tracks["data"])

pl_metadata["track_total"] = len(pl_tracks["data"])
return pl_metadata

Expand Down
5 changes: 5 additions & 0 deletions streamrip/client/downloadable.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ def __init__(self, session: aiohttp.ClientSession, info: dict):
qualities_available = [
i for i, size in enumerate(info["quality_to_size"]) if size > 0
]
# user uploaded tracks have negative ID's
if int(info.get("id", 0)) < 0:
# Always mp3
qualities_available = [1]

if len(qualities_available) == 0:
raise NonStreamableError(
"Missing download info. Skipping.",
Expand Down
12 changes: 12 additions & 0 deletions streamrip/media/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ async def resolve(self) -> Track | None:
if c.set_playlist_to_album:
album.album = self.playlist_name

playlist_tracks_in_subdirs = True
if playlist_tracks_in_subdirs:
c = self.config.session.filepaths

self.folder = os.path.join(
self.folder,
album.format_folder_path(c.folder_format),
)

self.cover_path = self.folder

quality = self.config.session.get_source(self.client.source).quality
try:
embedded_cover_path, downloadable = await asyncio.gather(
Expand All @@ -85,6 +96,7 @@ async def resolve(self) -> Track | None:

return Track(
meta,
album,
downloadable,
self.config,
self.folder,
Expand Down
43 changes: 34 additions & 9 deletions streamrip/media/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
@dataclass(slots=True)
class Track(Media):
meta: TrackMetadata
album: AlbumMetadata
downloadable: Downloadable
config: Config
folder: str
Expand All @@ -46,7 +47,14 @@ async def download(self):
f"Track {self.meta.tracknumber}",
) as callback:
try:
await self.downloadable.download(self.download_path, callback)
# Create temp file first so when the transfer is aborted
# We do not end with an incomplete file
# TODO: remove temp file on abort
if not os.path.isfile(self.download_path):
await self.downloadable.download(
self.download_path + ".tmp", callback
)
os.rename(self.download_path + ".tmp", self.download_path)
retry = False
except Exception as e:
logger.error(
Expand All @@ -63,7 +71,14 @@ async def download(self):
f"Track {self.meta.tracknumber} (retry)",
) as callback:
try:
await self.downloadable.download(self.download_path, callback)
# Create temp file first so when the transfer is aborted
# We do not end with an incomplete file
# TODO: remove temp file on abort
if not os.path.isfile(self.download_path):
await self.downloadable.download(
self.download_path + ".tmp", callback
)
os.rename(self.download_path + ".tmp", self.download_path)
except Exception as e:
logger.error(
f"Persistent error downloading track '{self.meta.title}', skipping: {e}"
Expand All @@ -75,12 +90,14 @@ async def download(self):
async def postprocess(self):
if self.is_single:
remove_title(self.meta.title)
# Sometimes downloads fails, and it wants to tag it, which can't without a file
# This happens with user uploaded files on Deezer from time to time
if os.path.isfile(self.download_path):
await tag_file(self.download_path, self.meta, self.cover_path)
if self.config.session.conversion.enabled:
await self._convert()

await tag_file(self.download_path, self.meta, self.cover_path)
if self.config.session.conversion.enabled:
await self._convert()

self.db.set_downloaded(self.meta.info.id)
self.db.set_downloaded(self.meta.info.id)

async def _convert(self):
c = self.config.session.conversion
Expand Down Expand Up @@ -161,8 +178,10 @@ async def resolve(self) -> Track | None:
else:
folder = self.folder


return Track(
meta,
self.album,
downloadable,
self.config,
folder,
Expand Down Expand Up @@ -200,7 +219,7 @@ async def resolve(self) -> Track | None:
try:
album = AlbumMetadata.from_track_resp(resp, self.client.source)
except Exception as e:
logger.error(f"Error building album metadata for track {id=}: {e}")
logger.error(f"Error building album metadata for track {self.id}: {e}")
return None

if album is None:
Expand All @@ -213,7 +232,7 @@ async def resolve(self) -> Track | None:
try:
meta = TrackMetadata.from_resp(album, self.client.source, resp)
except Exception as e:
logger.error(f"Error building track metadata for track {id=}: {e}")
logger.error(f"Error building track metadata for track {self.id}: {e}")
return None

if meta is None:
Expand All @@ -233,13 +252,19 @@ async def resolve(self) -> Track | None:
folder = parent

os.makedirs(folder, exist_ok=True)
c = self.config.session.filepaths

folder = os.path.join(
folder,
album.format_folder_path(c.folder_format),
)
embedded_cover_path, downloadable = await asyncio.gather(
self._download_cover(album.covers, folder),
self.client.get_downloadable(self.id, quality),
)
return Track(
meta,
album,
downloadable,
self.config,
folder,
Expand Down
14 changes: 10 additions & 4 deletions streamrip/metadata/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def format_folder_path(self, formatter: str) -> str:
"year": self.year,
"container": self.info.container,
}

return clean_filepath(formatter.format(**info))

@classmethod
Expand Down Expand Up @@ -457,9 +457,15 @@ def from_incomplete_deezer_track_resp(cls, resp: dict) -> AlbumMetadata | None:
album_id = album_resp["id"]
album = album_resp["title"]
covers = Covers.from_deezer(album_resp)
date = album_resp["release_date"]
year = date[:4]
albumartist = ", ".join(a["name"] for a in resp["contributors"])
date = album_resp.get("release_date")
if date is not None:
year = date[:4]
else:
year = None
if "contributors" in resp:
albumartist = ", ".join(a["name"] for a in resp["contributors"])
else:
albumartist = resp["artist"].get("name")
explicit = resp.get("explicit_lyrics", False)

info = AlbumInfo(
Expand Down
12 changes: 8 additions & 4 deletions streamrip/metadata/covers.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,14 @@ def from_qobuz(cls, resp):
@classmethod
def from_deezer(cls, resp):
c = cls()
c.set_cover_url("original", resp["cover_xl"])
c.set_cover_url("large", resp["cover_big"])
c.set_cover_url("small", resp["cover_medium"])
c.set_cover_url("thumbnail", resp["cover_small"])
if "cover_xl" in resp:
c.set_cover_url("original", resp["cover_xl"])
if "cover_big" in resp:
c.set_cover_url("large", resp["cover_big"])
if "cover_medium" in resp:
c.set_cover_url("small", resp["cover_medium"])
if "cover_small" in resp:
c.set_cover_url("thumbnail", resp["cover_small"])
return c

@classmethod
Expand Down
6 changes: 3 additions & 3 deletions streamrip/metadata/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ def from_deezer(cls, album: AlbumMetadata, resp) -> TrackMetadata | None:
work = None
title = typed(resp["title"], str)
artist = typed(resp["artist"]["name"], str)
tracknumber = typed(resp["track_position"], int)
discnumber = typed(resp["disk_number"], int)
tracknumber = typed(resp.get("track_position", 0), int)
discnumber = typed(resp.get("disk_number", 0), int)
composer = None
info = TrackInfo(
id=track_id,
Expand Down Expand Up @@ -212,7 +212,7 @@ def from_tidal(cls, album: AlbumMetadata, track) -> TrackMetadata:
discnumber=discnumber,
composer=None,
isrc=isrc,
lyrics=lyrics
lyrics=lyrics,
)

@classmethod
Expand Down
2 changes: 1 addition & 1 deletion streamrip/rip/parse_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ async def _extract_info_from_dynamic_link(
if match:
return match.group(1), match.group(2)

raise Exception("Unable to extract Deezer dynamic link.")
raise Exception(f"Unable to extract Deezer dynamic link from '{url}'")


class SoundcloudURL(URL):
Expand Down