mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-28 14:04:49 -04:00
Improve progress bars, soundcloud working
This commit is contained in:
parent
3640e4e70a
commit
f9b263a718
20 changed files with 213 additions and 86 deletions
|
@ -3,10 +3,11 @@ import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from . import progress
|
||||||
from .artwork import download_artwork
|
from .artwork import download_artwork
|
||||||
from .client import Client
|
from .client import Client
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .console import console
|
from .exceptions import NonStreamable
|
||||||
from .media import Media, Pending
|
from .media import Media, Pending
|
||||||
from .metadata import AlbumMetadata
|
from .metadata import AlbumMetadata
|
||||||
from .metadata.util import get_album_track_ids
|
from .metadata.util import get_album_track_ids
|
||||||
|
@ -24,20 +25,19 @@ class Album(Media):
|
||||||
folder: str
|
folder: str
|
||||||
|
|
||||||
async def preprocess(self):
|
async def preprocess(self):
|
||||||
if self.config.session.cli.text_output:
|
progress.add_title(self.meta.album)
|
||||||
console.print(
|
|
||||||
f"Downloading [cyan]{self.meta.album}[/cyan] by [cyan]{self.meta.albumartist}[/cyan]"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def download(self):
|
async def download(self):
|
||||||
async def _resolve_and_download(pending):
|
async def _resolve_and_download(pending: Pending):
|
||||||
track = await pending.resolve()
|
track = await pending.resolve()
|
||||||
|
if track is None:
|
||||||
|
return
|
||||||
await track.rip()
|
await track.rip()
|
||||||
|
|
||||||
await asyncio.gather(*[_resolve_and_download(p) for p in self.tracks])
|
await asyncio.gather(*[_resolve_and_download(p) for p in self.tracks])
|
||||||
|
|
||||||
async def postprocess(self):
|
async def postprocess(self):
|
||||||
pass
|
progress.remove_title(self.meta.album)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
@ -46,9 +46,17 @@ class PendingAlbum(Pending):
|
||||||
client: Client
|
client: Client
|
||||||
config: Config
|
config: Config
|
||||||
|
|
||||||
async def resolve(self):
|
async def resolve(self) -> Album | None:
|
||||||
resp = await self.client.get_metadata(self.id, "album")
|
resp = await self.client.get_metadata(self.id, "album")
|
||||||
meta = AlbumMetadata.from_resp(resp, self.client.source)
|
|
||||||
|
try:
|
||||||
|
meta = AlbumMetadata.from_album_resp(resp, self.client.source)
|
||||||
|
except NonStreamable:
|
||||||
|
logger.error(
|
||||||
|
f"Album {self.id} not available to stream on {self.client.source}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
tracklist = get_album_track_ids(self.client.source, resp)
|
tracklist = get_album_track_ids(self.client.source, resp)
|
||||||
folder = self.config.session.downloads.folder
|
folder = self.config.session.downloads.folder
|
||||||
album_folder = self._album_folder(folder, meta)
|
album_folder = self._album_folder(folder, meta)
|
||||||
|
|
|
@ -71,7 +71,7 @@ async def download_artwork(
|
||||||
)
|
)
|
||||||
|
|
||||||
_, embed_url, embed_cover_path = covers.get_size(config.embed_size)
|
_, embed_url, embed_cover_path = covers.get_size(config.embed_size)
|
||||||
if embed_cover_path is None and config.embed:
|
if embed_cover_path is None and embed:
|
||||||
assert embed_url is not None
|
assert embed_url is not None
|
||||||
embed_dir = os.path.join(folder, "__artwork")
|
embed_dir = os.path.join(folder, "__artwork")
|
||||||
os.makedirs(embed_dir, exist_ok=True)
|
os.makedirs(embed_dir, exist_ok=True)
|
||||||
|
@ -89,13 +89,13 @@ async def download_artwork(
|
||||||
await asyncio.gather(*downloadables)
|
await asyncio.gather(*downloadables)
|
||||||
|
|
||||||
# Update `covers` to reflect the current download state
|
# Update `covers` to reflect the current download state
|
||||||
if config.save_artwork:
|
if save_artwork:
|
||||||
assert saved_cover_path is not None
|
assert saved_cover_path is not None
|
||||||
covers.set_largest_path(saved_cover_path)
|
covers.set_largest_path(saved_cover_path)
|
||||||
if config.saved_max_width > 0:
|
if config.saved_max_width > 0:
|
||||||
downscale_image(saved_cover_path, config.saved_max_width)
|
downscale_image(saved_cover_path, config.saved_max_width)
|
||||||
|
|
||||||
if config.embed:
|
if embed:
|
||||||
assert embed_cover_path is not None
|
assert embed_cover_path is not None
|
||||||
covers.set_path(config.embed_size, embed_cover_path)
|
covers.set_path(config.embed_size, embed_cover_path)
|
||||||
if config.embed_max_width > 0:
|
if config.embed_max_width > 0:
|
||||||
|
|
|
@ -153,8 +153,9 @@ class MetadataConfig:
|
||||||
# Sets the value of the 'ALBUM' field in the metadata to the playlist's name.
|
# Sets the value of the 'ALBUM' field in the metadata to the playlist's name.
|
||||||
# This is useful if your music library software organizes tracks based on album name.
|
# This is useful if your music library software organizes tracks based on album name.
|
||||||
set_playlist_to_album: bool
|
set_playlist_to_album: bool
|
||||||
# Replaces the original track's tracknumber with it's position in the playlist
|
# If part of a playlist, sets the `tracknumber` field in the metadata to the track's
|
||||||
new_playlist_tracknumbers: bool
|
# position in the playlist instead of its position in its album
|
||||||
|
renumber_playlist_tracks: bool
|
||||||
# The following metadata tags won't be applied
|
# The following metadata tags won't be applied
|
||||||
# See https://github.com/nathom/streamrip/wiki/Metadata-Tag-Names for more info
|
# See https://github.com/nathom/streamrip/wiki/Metadata-Tag-Names for more info
|
||||||
exclude: list[str]
|
exclude: list[str]
|
||||||
|
@ -314,6 +315,20 @@ class ConfigData:
|
||||||
update_toml_section_from_config(self.toml["database"], self.database)
|
update_toml_section_from_config(self.toml["database"], self.database)
|
||||||
update_toml_section_from_config(self.toml["conversion"], self.conversion)
|
update_toml_section_from_config(self.toml["conversion"], self.conversion)
|
||||||
|
|
||||||
|
def get_source(
|
||||||
|
self, source: str
|
||||||
|
) -> QobuzConfig | DeezerConfig | SoundcloudConfig | TidalConfig:
|
||||||
|
d = {
|
||||||
|
"qobuz": self.qobuz,
|
||||||
|
"deezer": self.deezer,
|
||||||
|
"soundcloud": self.soundcloud,
|
||||||
|
"tidal": self.tidal,
|
||||||
|
}
|
||||||
|
res = d.get(source)
|
||||||
|
if res is None:
|
||||||
|
raise Exception(f"Invalid source {source}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
def update_toml_section_from_config(toml_section, config):
|
def update_toml_section_from_config(toml_section, config):
|
||||||
for field in fields(config):
|
for field in fields(config):
|
||||||
|
|
|
@ -141,8 +141,9 @@ saved_max_width = -1
|
||||||
# Sets the value of the 'ALBUM' field in the metadata to the playlist's name.
|
# Sets the value of the 'ALBUM' field in the metadata to the playlist's name.
|
||||||
# This is useful if your music library software organizes tracks based on album name.
|
# This is useful if your music library software organizes tracks based on album name.
|
||||||
set_playlist_to_album = true
|
set_playlist_to_album = true
|
||||||
# Replaces the original track's tracknumber with it's position in the playlist
|
# If part of a playlist, sets the `tracknumber` field in the metadata to the track's
|
||||||
new_playlist_tracknumbers = true
|
# position in the playlist instead of its position in its album
|
||||||
|
renumber_playlist_tracks = true
|
||||||
# The following metadata tags won't be applied
|
# The following metadata tags won't be applied
|
||||||
# See https://github.com/nathom/streamrip/wiki/Metadata-Tag-Names for more info
|
# See https://github.com/nathom/streamrip/wiki/Metadata-Tag-Names for more info
|
||||||
exclude = []
|
exclude = []
|
||||||
|
|
|
@ -264,3 +264,17 @@ class AAC(Converter):
|
||||||
|
|
||||||
def get_quality_arg(self, _: int) -> str:
|
def get_quality_arg(self, _: int) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get(codec: str) -> type[Converter]:
|
||||||
|
CONV_CLASS = {
|
||||||
|
"FLAC": FLAC,
|
||||||
|
"ALAC": ALAC,
|
||||||
|
"MP3": LAME,
|
||||||
|
"OPUS": OPUS,
|
||||||
|
"OGG": Vorbis,
|
||||||
|
"VORBIS": Vorbis,
|
||||||
|
"AAC": AAC,
|
||||||
|
"M4A": AAC,
|
||||||
|
}
|
||||||
|
return CONV_CLASS[codec.upper()]
|
||||||
|
|
|
@ -42,6 +42,7 @@ class Downloadable(ABC):
|
||||||
async def size(self) -> int:
|
async def size(self) -> int:
|
||||||
if self._size is not None:
|
if self._size is not None:
|
||||||
return self._size
|
return self._size
|
||||||
|
|
||||||
async with self.session.head(self.url) as response:
|
async with self.session.head(self.url) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
content_length = response.headers.get("Content-Length", 0)
|
content_length = response.headers.get("Content-Length", 0)
|
||||||
|
@ -231,11 +232,12 @@ class SoundcloudDownloadable(Downloadable):
|
||||||
return tmp
|
return tmp
|
||||||
|
|
||||||
async def size(self) -> int:
|
async def size(self) -> int:
|
||||||
async with self.session.get(self.url) as resp:
|
if self.file_type == "mp3":
|
||||||
content = await resp.text("utf-8")
|
async with self.session.get(self.url) as resp:
|
||||||
|
content = await resp.text("utf-8")
|
||||||
|
|
||||||
parsed_m3u = m3u8.loads(content)
|
parsed_m3u = m3u8.loads(content)
|
||||||
self._size = len(parsed_m3u.segments)
|
self._size = len(parsed_m3u.segments)
|
||||||
return await super().size()
|
return await super().size()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,6 @@ class Main:
|
||||||
|
|
||||||
async def rip(self):
|
async def rip(self):
|
||||||
await asyncio.gather(*[item.rip() for item in self.media])
|
await asyncio.gather(*[item.rip() for item in self.media])
|
||||||
|
|
||||||
for client in self.clients.values():
|
for client in self.clients.values():
|
||||||
if hasattr(client, "session"):
|
if hasattr(client, "session"):
|
||||||
await client.session.close()
|
await client.session.close()
|
||||||
|
|
|
@ -27,6 +27,6 @@ class Pending(ABC):
|
||||||
"""A request to download a `Media` whose metadata has not been fetched."""
|
"""A request to download a `Media` whose metadata has not been fetched."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def resolve(self) -> Media:
|
async def resolve(self) -> Media | None:
|
||||||
"""Fetch metadata and resolve into a downloadable `Media` object."""
|
"""Fetch metadata and resolve into a downloadable `Media` object."""
|
||||||
raise NotImplemented
|
raise NotImplemented
|
||||||
|
|
|
@ -5,6 +5,7 @@ import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..exceptions import NonStreamable
|
||||||
from .covers import Covers
|
from .covers import Covers
|
||||||
from .util import get_quality_id, safe_get, typed
|
from .util import get_quality_id, safe_get, typed
|
||||||
|
|
||||||
|
@ -114,8 +115,11 @@ class AlbumMetadata:
|
||||||
# Non-embedded information
|
# Non-embedded information
|
||||||
# version = resp.get("version")
|
# version = resp.get("version")
|
||||||
cover_urls = Covers.from_qobuz(resp)
|
cover_urls = Covers.from_qobuz(resp)
|
||||||
streamable = typed(resp.get("streamable", False), bool)
|
# streamable = typed(resp.get("streamable", False), bool)
|
||||||
assert streamable
|
#
|
||||||
|
# if not streamable:
|
||||||
|
# raise NonStreamable(resp)
|
||||||
|
|
||||||
bit_depth = typed(resp.get("maximum_bit_depth"), int | None)
|
bit_depth = typed(resp.get("maximum_bit_depth"), int | None)
|
||||||
sampling_rate = typed(resp.get("maximum_sampling_rate"), int | float | None)
|
sampling_rate = typed(resp.get("maximum_sampling_rate"), int | float | None)
|
||||||
quality = get_quality_id(bit_depth, sampling_rate)
|
quality = get_quality_id(bit_depth, sampling_rate)
|
||||||
|
@ -166,7 +170,6 @@ class AlbumMetadata:
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_soundcloud(cls, resp) -> AlbumMetadata:
|
def from_soundcloud(cls, resp) -> AlbumMetadata:
|
||||||
track = resp
|
track = resp
|
||||||
logger.debug(track)
|
|
||||||
track_id = track["id"]
|
track_id = track["id"]
|
||||||
bit_depth, sampling_rate = None, None
|
bit_depth, sampling_rate = None, None
|
||||||
explicit = typed(
|
explicit = typed(
|
||||||
|
@ -227,7 +230,7 @@ class AlbumMetadata:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_resp(cls, resp: dict, source: str) -> AlbumMetadata:
|
def from_track_resp(cls, resp: dict, source: str) -> AlbumMetadata:
|
||||||
if source == "qobuz":
|
if source == "qobuz":
|
||||||
return cls.from_qobuz(resp["album"])
|
return cls.from_qobuz(resp["album"])
|
||||||
if source == "tidal":
|
if source == "tidal":
|
||||||
|
@ -237,3 +240,15 @@ class AlbumMetadata:
|
||||||
if source == "deezer":
|
if source == "deezer":
|
||||||
return cls.from_deezer(resp["album"])
|
return cls.from_deezer(resp["album"])
|
||||||
raise Exception("Invalid source")
|
raise Exception("Invalid source")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_album_resp(cls, resp: dict, source: str) -> AlbumMetadata:
|
||||||
|
if source == "qobuz":
|
||||||
|
return cls.from_qobuz(resp)
|
||||||
|
if source == "tidal":
|
||||||
|
return cls.from_tidal(resp)
|
||||||
|
if source == "soundcloud":
|
||||||
|
return cls.from_soundcloud(resp)
|
||||||
|
if source == "deezer":
|
||||||
|
return cls.from_deezer(resp)
|
||||||
|
raise Exception("Invalid source")
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from .album_metadata import AlbumMetadata
|
from .album_metadata import AlbumMetadata
|
||||||
|
@ -8,6 +9,8 @@ NON_STREAMABLE = "_non_streamable"
|
||||||
ORIGINAL_DOWNLOAD = "_original_download"
|
ORIGINAL_DOWNLOAD = "_original_download"
|
||||||
NOT_RESOLVED = "_not_resolved"
|
NOT_RESOLVED = "_not_resolved"
|
||||||
|
|
||||||
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
||||||
|
|
||||||
def get_soundcloud_id(resp: dict) -> str:
|
def get_soundcloud_id(resp: dict) -> str:
|
||||||
item_id = resp["id"]
|
item_id = resp["id"]
|
||||||
|
@ -44,11 +47,19 @@ class PlaylistMetadata:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_qobuz(cls, resp: dict):
|
def from_qobuz(cls, resp: dict):
|
||||||
name = typed(resp["title"], str)
|
logger.debug(resp)
|
||||||
tracks = [
|
name = typed(resp["name"], str)
|
||||||
TrackMetadata.from_qobuz(AlbumMetadata.from_qobuz(track["album"]), track)
|
tracks = []
|
||||||
for track in resp["tracks"]["items"]
|
|
||||||
]
|
for i, track in enumerate(resp["tracks"]["items"]):
|
||||||
|
meta = TrackMetadata.from_qobuz(
|
||||||
|
AlbumMetadata.from_qobuz(track["album"]), track
|
||||||
|
)
|
||||||
|
if meta is None:
|
||||||
|
logger.error(f"Track {i+1} in playlist {name} not available for stream")
|
||||||
|
continue
|
||||||
|
tracks.append(meta)
|
||||||
|
|
||||||
return cls(name, tracks)
|
return cls(name, tracks)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..exceptions import NonStreamable
|
||||||
from .album_metadata import AlbumMetadata
|
from .album_metadata import AlbumMetadata
|
||||||
from .util import safe_get, typed
|
from .util import safe_get, typed
|
||||||
|
|
||||||
|
@ -30,8 +31,13 @@ class TrackMetadata:
|
||||||
composer: str | None
|
composer: str | None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata:
|
def from_qobuz(cls, album: AlbumMetadata, resp: dict) -> TrackMetadata | None:
|
||||||
title = typed(resp["title"].strip(), str)
|
title = typed(resp["title"].strip(), str)
|
||||||
|
streamable = typed(resp.get("streamable", False), bool)
|
||||||
|
|
||||||
|
if not streamable:
|
||||||
|
return None
|
||||||
|
|
||||||
version = typed(resp.get("version"), str | None)
|
version = typed(resp.get("version"), str | None)
|
||||||
work = typed(resp.get("work"), str | None)
|
work = typed(resp.get("work"), str | None)
|
||||||
if version is not None and version not in title:
|
if version is not None and version not in title:
|
||||||
|
@ -114,7 +120,7 @@ class TrackMetadata:
|
||||||
raise NotImplemented
|
raise NotImplemented
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_resp(cls, album: AlbumMetadata, source, resp) -> TrackMetadata:
|
def from_resp(cls, album: AlbumMetadata, source, resp) -> TrackMetadata | None:
|
||||||
if source == "qobuz":
|
if source == "qobuz":
|
||||||
return cls.from_qobuz(album, resp)
|
return cls.from_qobuz(album, resp)
|
||||||
if source == "tidal":
|
if source == "tidal":
|
||||||
|
|
|
@ -3,6 +3,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from . import progress
|
||||||
from .artwork import download_artwork
|
from .artwork import download_artwork
|
||||||
from .client import Client
|
from .client import Client
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
@ -20,13 +21,25 @@ class PendingPlaylistTrack(Pending):
|
||||||
client: Client
|
client: Client
|
||||||
config: Config
|
config: Config
|
||||||
folder: str
|
folder: str
|
||||||
|
playlist_name: str
|
||||||
|
position: int
|
||||||
|
|
||||||
async def resolve(self) -> Track:
|
async def resolve(self) -> Track | None:
|
||||||
resp = await self.client.get_metadata(self.id, "track")
|
resp = await self.client.get_metadata(self.id, "track")
|
||||||
album = AlbumMetadata.from_resp(resp["album"], self.client.source)
|
|
||||||
|
album = AlbumMetadata.from_resp(resp, self.client.source)
|
||||||
meta = TrackMetadata.from_resp(album, self.client.source, resp)
|
meta = TrackMetadata.from_resp(album, self.client.source, resp)
|
||||||
quality = getattr(self.config.session, self.client.source).quality
|
if meta is None:
|
||||||
assert isinstance(quality, int)
|
logger.error(f"Cannot stream track ({self.id}) on {self.client.source}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
c = self.config.session.metadata
|
||||||
|
if c.renumber_playlist_tracks:
|
||||||
|
meta.tracknumber = self.position
|
||||||
|
if c.set_playlist_to_album:
|
||||||
|
album.album = self.playlist_name
|
||||||
|
|
||||||
|
quality = self.config.session.get_source(self.client.source).quality
|
||||||
embedded_cover_path, downloadable = await asyncio.gather(
|
embedded_cover_path, downloadable = await asyncio.gather(
|
||||||
self._download_cover(album.covers, self.folder),
|
self._download_cover(album.covers, self.folder),
|
||||||
self.client.get_downloadable(self.id, quality),
|
self.client.get_downloadable(self.id, quality),
|
||||||
|
@ -55,11 +68,16 @@ class Playlist(Media):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def download(self):
|
async def download(self):
|
||||||
async def _resolve_and_download(pending):
|
progress.add_title(self.name)
|
||||||
|
|
||||||
|
async def _resolve_and_download(pending: PendingPlaylistTrack):
|
||||||
track = await pending.resolve()
|
track = await pending.resolve()
|
||||||
|
if track is None:
|
||||||
|
return
|
||||||
await track.rip()
|
await track.rip()
|
||||||
|
|
||||||
await asyncio.gather(*[_resolve_and_download(p) for p in self.tracks])
|
await asyncio.gather(*[_resolve_and_download(p) for p in self.tracks])
|
||||||
|
progress.remove_title(self.name)
|
||||||
|
|
||||||
async def postprocess(self):
|
async def postprocess(self):
|
||||||
pass
|
pass
|
||||||
|
@ -71,14 +89,16 @@ class PendingPlaylist(Pending):
|
||||||
client: Client
|
client: Client
|
||||||
config: Config
|
config: Config
|
||||||
|
|
||||||
async def resolve(self):
|
async def resolve(self) -> Playlist | None:
|
||||||
resp = await self.client.get_metadata(self.id, "playlist")
|
resp = await self.client.get_metadata(self.id, "playlist")
|
||||||
meta = PlaylistMetadata.from_resp(resp, self.client.source)
|
meta = PlaylistMetadata.from_resp(resp, self.client.source)
|
||||||
name = meta.name
|
name = meta.name
|
||||||
parent = self.config.session.downloads.folder
|
parent = self.config.session.downloads.folder
|
||||||
folder = os.path.join(parent, clean_filename(name))
|
folder = os.path.join(parent, clean_filename(name))
|
||||||
tracks = [
|
tracks = [
|
||||||
PendingPlaylistTrack(id, self.client, self.config, folder)
|
PendingPlaylistTrack(
|
||||||
for id in meta.ids()
|
id, self.client, self.config, folder, name, position + 1
|
||||||
|
)
|
||||||
|
for position, id in enumerate(meta.ids())
|
||||||
]
|
]
|
||||||
return Playlist(name, self.config, self.client, tracks)
|
return Playlist(name, self.config, self.client, tracks)
|
||||||
|
|
|
@ -1,54 +1,83 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from click import style
|
from rich.console import Group
|
||||||
|
from rich.live import Live
|
||||||
from rich.progress import Progress
|
from rich.progress import Progress
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from .console import console
|
from .console import console
|
||||||
|
|
||||||
THEMES = {
|
|
||||||
"plain": None,
|
|
||||||
"dainty": (
|
|
||||||
"{desc} |{bar}| "
|
|
||||||
+ style("{remaining}", fg="magenta")
|
|
||||||
+ " left at "
|
|
||||||
+ style("{rate_fmt}{postfix} ", fg="cyan", bold=True)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressManager:
|
class ProgressManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.started = False
|
self.started = False
|
||||||
self.progress = Progress(console=console)
|
self.progress = Progress(console=console)
|
||||||
|
self.prefix = Text.assemble(("Downloading ", "bold cyan"), overflow="ellipsis")
|
||||||
|
self.live = Live(Group(self.prefix, self.progress), refresh_per_second=10)
|
||||||
|
self.task_titles = []
|
||||||
|
|
||||||
def get_callback(self, total: int, desc: str):
|
def get_callback(self, total: int, desc: str):
|
||||||
if not self.started:
|
if not self.started:
|
||||||
self.progress.start()
|
self.live.start()
|
||||||
self.started = True
|
self.started = True
|
||||||
|
|
||||||
task = self.progress.add_task(f"[cyan]{desc}", total=total)
|
task = self.progress.add_task(f"[cyan]{desc}", total=total)
|
||||||
|
|
||||||
def _callback(x: int):
|
def _callback_update(x: int):
|
||||||
self.progress.update(task, advance=x)
|
self.progress.update(task, advance=x)
|
||||||
|
self.live.update(Group(self.get_title_text(), self.progress))
|
||||||
|
|
||||||
return _callback
|
def _callback_done():
|
||||||
|
self.progress.update(task, visible=False)
|
||||||
|
|
||||||
|
return Handle(_callback_update, _callback_done)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
if self.started:
|
if self.started:
|
||||||
self.progress.stop()
|
self.live.stop()
|
||||||
|
|
||||||
|
def add_title(self, title: str):
|
||||||
|
self.task_titles.append(title)
|
||||||
|
|
||||||
|
def remove_title(self, title: str):
|
||||||
|
self.task_titles.remove(title)
|
||||||
|
|
||||||
|
def get_title_text(self) -> Text:
|
||||||
|
t = self.prefix + Text(", ".join(self.task_titles))
|
||||||
|
t.overflow = "ellipsis"
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Handle:
|
||||||
|
update: Callable[[int], None]
|
||||||
|
done: Callable[[], None]
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.update
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
self.done()
|
||||||
|
|
||||||
|
|
||||||
# global instance
|
# global instance
|
||||||
_p = ProgressManager()
|
_p = ProgressManager()
|
||||||
|
|
||||||
|
|
||||||
def get_progress_callback(
|
def get_progress_callback(enabled: bool, total: int, desc: str) -> Handle:
|
||||||
enabled: bool, total: int, desc: str
|
|
||||||
) -> Callable[[int], None]:
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
return lambda _: None
|
return Handle(lambda _: None, lambda: None)
|
||||||
return _p.get_callback(total, desc)
|
return _p.get_callback(total, desc)
|
||||||
|
|
||||||
|
|
||||||
|
def add_title(title: str):
|
||||||
|
_p.add_title(title)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_title(title: str):
|
||||||
|
_p.remove_title(title)
|
||||||
|
|
||||||
|
|
||||||
def clear_progress():
|
def clear_progress():
|
||||||
_p.cleanup()
|
_p.cleanup()
|
||||||
|
|
|
@ -167,9 +167,8 @@ class QobuzClient(Client):
|
||||||
assert status == 200
|
assert status == 200
|
||||||
yield resp
|
yield resp
|
||||||
|
|
||||||
async def get_downloadable(self, item: dict, quality: int) -> Downloadable:
|
async def get_downloadable(self, item_id: str, quality: int) -> Downloadable:
|
||||||
assert self.secret is not None and self.logged_in and 1 <= quality <= 4
|
assert self.secret is not None and self.logged_in and 1 <= quality <= 4
|
||||||
item_id = item["id"]
|
|
||||||
status, resp_json = await self._request_file_url(item_id, quality, self.secret)
|
status, resp_json = await self._request_file_url(item_id, quality, self.secret)
|
||||||
assert status == 200
|
assert status == 200
|
||||||
stream_url = resp_json.get("url")
|
stream_url = resp_json.get("url")
|
||||||
|
|
|
@ -57,7 +57,9 @@ class SoundcloudClient(Client):
|
||||||
API response.
|
API response.
|
||||||
"""
|
"""
|
||||||
if media_type == "track":
|
if media_type == "track":
|
||||||
return await self._get_track(item_id)
|
# parse custom id that we injected
|
||||||
|
_item_id, _ = item_id.split("|")
|
||||||
|
return await self._get_track(_item_id)
|
||||||
elif media_type == "playlist":
|
elif media_type == "playlist":
|
||||||
return await self._get_playlist(item_id)
|
return await self._get_playlist(item_id)
|
||||||
else:
|
else:
|
||||||
|
@ -143,8 +145,10 @@ class SoundcloudClient(Client):
|
||||||
# if download_url == '_non_streamable' then we raise an exception
|
# if download_url == '_non_streamable' then we raise an exception
|
||||||
|
|
||||||
infos: list[str] = item_info.split("|")
|
infos: list[str] = item_info.split("|")
|
||||||
|
logger.debug(f"{infos=}")
|
||||||
assert len(infos) == 2, infos
|
assert len(infos) == 2, infos
|
||||||
item_id, download_info = infos
|
item_id, download_info = infos
|
||||||
|
assert re.match(r"\d+", item_id) is not None
|
||||||
|
|
||||||
if download_info == self.NON_STREAMABLE:
|
if download_info == self.NON_STREAMABLE:
|
||||||
raise NonStreamable(item_info)
|
raise NonStreamable(item_info)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@ -7,6 +8,7 @@ from .artwork import download_artwork
|
||||||
from .client import Client
|
from .client import Client
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .downloadable import Downloadable
|
from .downloadable import Downloadable
|
||||||
|
from .exceptions import NonStreamable
|
||||||
from .filepath_utils import clean_filename
|
from .filepath_utils import clean_filename
|
||||||
from .media import Media, Pending
|
from .media import Media, Pending
|
||||||
from .metadata import AlbumMetadata, Covers, TrackMetadata
|
from .metadata import AlbumMetadata, Covers, TrackMetadata
|
||||||
|
@ -14,6 +16,8 @@ from .progress import get_progress_callback
|
||||||
from .semaphore import global_download_semaphore
|
from .semaphore import global_download_semaphore
|
||||||
from .tagger import tag_file
|
from .tagger import tag_file
|
||||||
|
|
||||||
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Track(Media):
|
class Track(Media):
|
||||||
|
@ -33,12 +37,12 @@ class Track(Media):
|
||||||
async def download(self):
|
async def download(self):
|
||||||
# TODO: progress bar description
|
# TODO: progress bar description
|
||||||
async with global_download_semaphore(self.config.session.downloads):
|
async with global_download_semaphore(self.config.session.downloads):
|
||||||
callback = get_progress_callback(
|
with get_progress_callback(
|
||||||
self.config.session.cli.progress_bars,
|
self.config.session.cli.progress_bars,
|
||||||
await self.downloadable.size(),
|
await self.downloadable.size(),
|
||||||
f"Track {self.meta.tracknumber}",
|
f"Track {self.meta.tracknumber}",
|
||||||
)
|
) as callback:
|
||||||
await self.downloadable.download(self.download_path, callback)
|
await self.downloadable.download(self.download_path, callback)
|
||||||
|
|
||||||
async def postprocess(self):
|
async def postprocess(self):
|
||||||
await self._tag()
|
await self._tag()
|
||||||
|
@ -52,19 +56,9 @@ class Track(Media):
|
||||||
await tag_file(self.download_path, self.meta, self.cover_path)
|
await tag_file(self.download_path, self.meta, self.cover_path)
|
||||||
|
|
||||||
async def _convert(self):
|
async def _convert(self):
|
||||||
CONV_CLASS: dict[str, type[converter.Converter]] = {
|
|
||||||
"FLAC": converter.FLAC,
|
|
||||||
"ALAC": converter.ALAC,
|
|
||||||
"MP3": converter.LAME,
|
|
||||||
"OPUS": converter.OPUS,
|
|
||||||
"OGG": converter.Vorbis,
|
|
||||||
"VORBIS": converter.Vorbis,
|
|
||||||
"AAC": converter.AAC,
|
|
||||||
"M4A": converter.AAC,
|
|
||||||
}
|
|
||||||
c = self.config.session.conversion
|
c = self.config.session.conversion
|
||||||
codec = c.codec
|
engine_class = converter.get(c.codec)
|
||||||
engine = CONV_CLASS[codec.upper()](
|
engine = engine_class(
|
||||||
filename=self.download_path,
|
filename=self.download_path,
|
||||||
sampling_rate=c.sampling_rate,
|
sampling_rate=c.sampling_rate,
|
||||||
bit_depth=c.bit_depth,
|
bit_depth=c.bit_depth,
|
||||||
|
@ -97,9 +91,15 @@ class PendingTrack(Pending):
|
||||||
# cover_path is None <==> Artwork for this track doesn't exist in API
|
# cover_path is None <==> Artwork for this track doesn't exist in API
|
||||||
cover_path: str | None
|
cover_path: str | None
|
||||||
|
|
||||||
async def resolve(self) -> Track:
|
async def resolve(self) -> Track | None:
|
||||||
resp = await self.client.get_metadata(self.id, "track")
|
resp = await self.client.get_metadata(self.id, "track")
|
||||||
meta = TrackMetadata.from_resp(self.album, self.client.source, resp)
|
meta = TrackMetadata.from_resp(self.album, self.client.source, resp)
|
||||||
|
if meta is None:
|
||||||
|
logger.error(
|
||||||
|
f"Track {self.id} not available for stream on {self.client.source}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
quality = getattr(self.config.session, self.client.source).quality
|
quality = getattr(self.config.session, self.client.source).quality
|
||||||
assert isinstance(quality, int)
|
assert isinstance(quality, int)
|
||||||
downloadable = await self.client.get_downloadable(self.id, quality)
|
downloadable = await self.client.get_downloadable(self.id, quality)
|
||||||
|
@ -118,13 +118,17 @@ class PendingSingle(Pending):
|
||||||
client: Client
|
client: Client
|
||||||
config: Config
|
config: Config
|
||||||
|
|
||||||
async def resolve(self) -> Track:
|
async def resolve(self) -> Track | None:
|
||||||
resp = await self.client.get_metadata(self.id, "track")
|
resp = await self.client.get_metadata(self.id, "track")
|
||||||
# Patch for soundcloud
|
# Patch for soundcloud
|
||||||
# self.id = resp["id"]
|
# self.id = resp["id"]
|
||||||
album = AlbumMetadata.from_resp(resp, self.client.source)
|
album = AlbumMetadata.from_track_resp(resp, self.client.source)
|
||||||
meta = TrackMetadata.from_resp(album, self.client.source, resp)
|
meta = TrackMetadata.from_resp(album, self.client.source, resp)
|
||||||
|
|
||||||
|
if meta is None:
|
||||||
|
logger.error(f"Cannot stream track ({self.id}) on {self.client.source}")
|
||||||
|
return None
|
||||||
|
|
||||||
quality = getattr(self.config.session, self.client.source).quality
|
quality = getattr(self.config.session, self.client.source).quality
|
||||||
assert isinstance(quality, int)
|
assert isinstance(quality, int)
|
||||||
folder = os.path.join(
|
folder = os.path.join(
|
||||||
|
|
|
@ -54,6 +54,8 @@ class GenericURL(URL):
|
||||||
return PendingSingle(item_id, client, config)
|
return PendingSingle(item_id, client, config)
|
||||||
elif media_type == "album":
|
elif media_type == "album":
|
||||||
return PendingAlbum(item_id, client, config)
|
return PendingAlbum(item_id, client, config)
|
||||||
|
elif media_type == "playlist":
|
||||||
|
return PendingPlaylist(item_id, client, config)
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -92,7 +92,7 @@ def test_sample_config_data_fields(sample_config_data):
|
||||||
saved_max_width=-1,
|
saved_max_width=-1,
|
||||||
),
|
),
|
||||||
metadata=MetadataConfig(
|
metadata=MetadataConfig(
|
||||||
set_playlist_to_album=True, new_playlist_tracknumbers=True, exclude=[]
|
set_playlist_to_album=True, renumber_playlist_tracks=True, exclude=[]
|
||||||
),
|
),
|
||||||
qobuz_filters=QobuzDiscographyFilterConfig(
|
qobuz_filters=QobuzDiscographyFilterConfig(
|
||||||
extras=False,
|
extras=False,
|
||||||
|
@ -102,7 +102,6 @@ def test_sample_config_data_fields(sample_config_data):
|
||||||
non_studio_albums=False,
|
non_studio_albums=False,
|
||||||
non_remaster=False,
|
non_remaster=False,
|
||||||
),
|
),
|
||||||
theme=ThemeConfig(progress_bar="dainty"),
|
|
||||||
database=DatabaseConfig(
|
database=DatabaseConfig(
|
||||||
downloads_enabled=True,
|
downloads_enabled=True,
|
||||||
downloads_path="downloadspath",
|
downloads_path="downloadspath",
|
||||||
|
@ -130,7 +129,6 @@ def test_sample_config_data_fields(sample_config_data):
|
||||||
assert sample_config_data.filepaths == test_config.filepaths
|
assert sample_config_data.filepaths == test_config.filepaths
|
||||||
assert sample_config_data.metadata == test_config.metadata
|
assert sample_config_data.metadata == test_config.metadata
|
||||||
assert sample_config_data.qobuz_filters == test_config.qobuz_filters
|
assert sample_config_data.qobuz_filters == test_config.qobuz_filters
|
||||||
assert sample_config_data.theme == test_config.theme
|
|
||||||
assert sample_config_data.database == test_config.database
|
assert sample_config_data.database == test_config.database
|
||||||
assert sample_config_data.conversion == test_config.conversion
|
assert sample_config_data.conversion == test_config.conversion
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ from streamrip.config import *
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def toml():
|
def toml():
|
||||||
with open("streamrip/config.toml") as f:
|
with open("streamrip/config.toml") as f:
|
||||||
t = tomlkit.parse(f.read())
|
t = tomlkit.parse(f.read()) # type: ignore
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue