diff --git a/streamrip/album.py b/streamrip/album.py index da3fcd3..120f509 100644 --- a/streamrip/album.py +++ b/streamrip/album.py @@ -1,20 +1,42 @@ import asyncio +import logging +import os from dataclasses import dataclass from .artwork import download_artwork from .client import Client from .config import Config +from .console import console from .media import Media, Pending from .metadata import AlbumMetadata, get_album_track_ids from .track import PendingTrack, Track +logger = logging.getLogger("streamrip") + @dataclass(slots=True) class Album(Media): meta: AlbumMetadata - tracks: list[Track] + tracks: list[PendingTrack] config: Config - directory: str + # folder where the tracks will be downloaded + folder: str + + async def preprocess(self): + if self.config.session.cli.text_output: + console.print( + f"[cyan]Downloading {self.meta.album} by {self.meta.albumartist}" + ) + + async def download(self): + async def _resolve_and_download(pending): + track = await pending.resolve() + await track.rip() + + await asyncio.gather(*[_resolve_and_download(p) for p in self.tracks]) + + async def postprocess(self): + pass @dataclass(slots=True) @@ -28,7 +50,8 @@ class PendingAlbum(Pending): meta = AlbumMetadata.from_resp(resp, self.client.source) tracklist = get_album_track_ids(self.client.source, resp) folder = self.config.session.downloads.folder - album_folder = self._album_folder(folder, meta.album) + album_folder = self._album_folder(folder, meta) + os.makedirs(album_folder, exist_ok=True) embed_cover, _ = await download_artwork( self.client.session, album_folder, meta.covers, self.config.session.artwork ) @@ -43,12 +66,10 @@ class PendingAlbum(Pending): ) for id in tracklist ] - tracks: list[Track] = await asyncio.gather( - *(track.resolve() for track in pending_tracks) - ) - return Album(meta, tracks, self.config, album_folder) + logger.debug("Pending tracks: %s", pending_tracks) + return Album(meta, pending_tracks, self.config, album_folder) - def _album_folder(self, parent: str, album_name: str) -> str: - # find name of album folder - # create album folder if it doesnt exist - raise NotImplementedError + def _album_folder(self, parent: str, meta: AlbumMetadata) -> str: + formatter = self.config.session.filepaths.folder_format + folder = meta.format_folder_path(formatter) + return os.path.join(parent, folder) diff --git a/streamrip/artist.py b/streamrip/artist.py index fd36423..c4b3387 100644 --- a/streamrip/artist.py +++ b/streamrip/artist.py @@ -1,6 +1,12 @@ +from .album import Album, PendingAlbum +from .client import Client +from .config import Config +from .media import Media, Pending + + class Artist(Media): name: str - albums: list[Album] + albums: list[PendingAlbum] config: Config diff --git a/streamrip/cli2.py b/streamrip/cli2.py index 0f7f018..16c66d3 100644 --- a/streamrip/cli2.py +++ b/streamrip/cli2.py @@ -12,15 +12,10 @@ from rich.logging import RichHandler from rich.traceback import install from .config import Config, set_user_defaults +from .console import console from .main import Main from .user_paths import BLANK_CONFIG_PATH, CONFIG_PATH -logging.basicConfig( - level="DEBUG", format="%(message)s", datefmt="[%X]", handlers=[RichHandler()] -) - -logger = logging.getLogger("streamrip") - def echo_i(msg, **kwargs): secho(msg, fg="green", **kwargs) @@ -59,12 +54,23 @@ def rip(ctx, config_path, verbose): """ Streamrip: the all in one music downloader. """ + global logger + FORMAT = "%(message)s" + logging.basicConfig( + level="WARNING", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] + ) + logger = logging.getLogger("streamrip") if verbose: - install(suppress=[click], show_locals=True, locals_hide_sunder=False) + install( + console=console, + suppress=[click], + show_locals=True, + locals_hide_sunder=False, + ) logger.setLevel(logging.DEBUG) logger.debug("Showing all debug logs") else: - install(suppress=[click, asyncio], max_frames=1) + install(console=console, suppress=[click, asyncio], max_frames=1) logger.setLevel(logging.WARNING) ctx.ensure_object(dict) @@ -112,8 +118,7 @@ async def file(ctx, path): with Config(config_path) as cfg: main = Main(cfg) with open(path) as f: - for u in f: - await main.add(u) + await asyncio.gather(*[main.add(url) for url in f]) await main.resolve() await main.rip() diff --git a/streamrip/config.py b/streamrip/config.py index 938805e..0dfed7b 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -205,9 +205,11 @@ class LastFmConfig: @dataclass(slots=True) -class ThemeConfig: - # Options: "dainty" or "plain" - progress_bar: str +class CliConfig: + # Print "Downloading {Album name}" etc. to screen + text_output: bool + # Show resolve, download progress bars + progress_bars: bool @dataclass(slots=True) @@ -232,7 +234,7 @@ class ConfigData: metadata: MetadataConfig qobuz_filters: QobuzDiscographyFilterConfig - theme: ThemeConfig + cli: CliConfig database: DatabaseConfig conversion: ConversionConfig @@ -260,7 +262,7 @@ class ConfigData: filepaths = FilepathsConfig(**toml["filepaths"]) # type: ignore metadata = MetadataConfig(**toml["metadata"]) # type: ignore qobuz_filters = QobuzDiscographyFilterConfig(**toml["qobuz_filters"]) # type: ignore - theme = ThemeConfig(**toml["theme"]) # type: ignore + cli = CliConfig(**toml["cli"]) # type: ignore database = DatabaseConfig(**toml["database"]) # type: ignore conversion = ConversionConfig(**toml["conversion"]) # type: ignore misc = MiscConfig(**toml["misc"]) # type: ignore @@ -278,7 +280,7 @@ class ConfigData: filepaths=filepaths, metadata=metadata, qobuz_filters=qobuz_filters, - theme=theme, + cli=cli, database=database, conversion=conversion, misc=misc, @@ -308,7 +310,7 @@ class ConfigData: update_toml_section_from_config(self.toml["filepaths"], self.filepaths) update_toml_section_from_config(self.toml["metadata"], self.metadata) update_toml_section_from_config(self.toml["qobuz_filters"], self.qobuz_filters) - update_toml_section_from_config(self.toml["theme"], self.theme) + update_toml_section_from_config(self.toml["cli"], self.cli) update_toml_section_from_config(self.toml["database"], self.database) update_toml_section_from_config(self.toml["conversion"], self.conversion) diff --git a/streamrip/config.toml b/streamrip/config.toml index 887179d..4d81264 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -15,7 +15,7 @@ concurrency = true max_connections = 3 # Max number of API requests to handle per minute # Set to -1 for no limit -requests_per_minute = -1 +requests_per_minute = 60 [qobuz] # 1: 320kbps MP3, 2: 16/44.1, 3: 24/<=96, 4: 24/>=96 @@ -172,9 +172,11 @@ source = "qobuz" # on this one. fallback_source = "deezer" -[theme] -# Options: "dainty" or "plain" -progress_bar = "dainty" +[cli] +# Print "Downloading {Album name}" etc. to screen +text_output = true +# Show resolve, download progress bars +progress_bars = true [misc] # Metadata to identify this config file. Do not change. diff --git a/streamrip/console.py b/streamrip/console.py new file mode 100644 index 0000000..a9463af --- /dev/null +++ b/streamrip/console.py @@ -0,0 +1,3 @@ +from rich.console import Console + +console = Console() diff --git a/streamrip/converter.py b/streamrip/converter.py index a371d67..fb55f36 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -1,9 +1,9 @@ """Wrapper classes over FFMPEG.""" +import asyncio import logging import os import shutil -import subprocess from tempfile import gettempdir from typing import Optional @@ -68,7 +68,7 @@ class Converter: logger.debug("FFmpeg codec extra argument: %s", self.ffmpeg_arg) - def convert(self, custom_fn: Optional[str] = None): + async def convert(self, custom_fn: Optional[str] = None): """Convert the file. :param custom_fn: Custom output filename (defaults to the original @@ -81,8 +81,10 @@ class Converter: self.command = self._gen_command() logger.debug("Generated conversion command: %s", self.command) - process = subprocess.Popen(self.command, stderr=subprocess.PIPE) - process.wait() + process = await asyncio.create_subprocess_exec( + *self.command, stderr=asyncio.subprocess.PIPE + ) + out, err = await process.communicate() if process.returncode == 0 and os.path.isfile(self.tempfile): if self.remove_source: os.remove(self.filename) @@ -91,7 +93,7 @@ class Converter: shutil.move(self.tempfile, self.final_fn) logger.debug("Moved: %s -> %s", self.tempfile, self.final_fn) else: - raise ConversionError(f"FFmpeg output:\n{process.communicate()[1]}") + raise ConversionError(f"FFmpeg output:\n{out, err}") def _gen_command(self): command = [ @@ -172,7 +174,7 @@ class LAME(Converter): https://trac.ffmpeg.org/wiki/Encode/MP3 """ - __bitrate_map = { + _bitrate_map = { 320: "-b:a 320k", 245: "-q:a 0", 225: "-q:a 1", @@ -192,7 +194,7 @@ class LAME(Converter): default_ffmpeg_arg = "-q:a 0" # V0 def get_quality_arg(self, rate): - return self.__bitrate_map[rate] + return self._bitrate_map[rate] class ALAC(Converter): diff --git a/streamrip/main.py b/streamrip/main.py index 865e86c..d40f340 100644 --- a/streamrip/main.py +++ b/streamrip/main.py @@ -1,14 +1,13 @@ import asyncio import logging -from click import secho - from .client import Client from .config import Config +from .console import console from .media import Media, Pending +from .progress import clear_progress from .prompter import get_prompter from .qobuz_client import QobuzClient -from .thread_pool import AsyncThreadPool from .universal_url import parse_url logger = logging.getLogger("streamrip") @@ -26,7 +25,8 @@ class Main: def __init__(self, config: Config): # Pipeline: - # input URL -> (URL) -> (Pending) -> (Media) -> (Downloadable) -> downloaded audio file + # input URL -> (URL) -> (Pending) -> (Media) -> (Downloadable) + # -> downloaded audio file self.pending: list[Pending] = [] self.media: list[Media] = [] @@ -42,11 +42,11 @@ class Main: async def add(self, url: str): parsed = parse_url(url) if parsed is None: - secho(f"Unable to parse url {url}", fg="red") - raise Exception + raise Exception(f"Unable to parse url {url}") client = await self.get_logged_in_client(parsed.source) self.pending.append(await parsed.into_pending(client, self.config)) + logger.debug("Added url=%s", url) async def get_logged_in_client(self, source: str): client = self.clients[source] @@ -57,30 +57,25 @@ class Main: await prompter.prompt_and_login() prompter.save() else: - # Log into client using credentials from config - await client.login() + with console.status(f"[cyan]Logging into {source}", spinner="dots"): + # Log into client using credentials from config + await client.login() assert client.logged_in return client async def resolve(self): - logger.info(f"Resolving {len(self.pending)} items") - assert len(self.pending) != 0 - coros = [p.resolve() for p in self.pending] - new_media: list[Media] = await asyncio.gather(*coros) + with console.status("Resolving URLs...", spinner="dots"): + coros = [p.resolve() for p in self.pending] + new_media: list[Media] = await asyncio.gather(*coros) + self.media.extend(new_media) self.pending.clear() - assert len(self.pending) == 0 async def rip(self): - c = self.config.session.downloads - if c.concurrency: - max_connections = c.max_connections if c.max_connections > 0 else 9999 - else: - max_connections = 1 - - async with AsyncThreadPool(max_connections) as pool: - await pool.gather([item.rip() for item in self.media]) + await asyncio.gather(*[item.rip() for item in self.media]) for client in self.clients.values(): await client.session.close() + + clear_progress() diff --git a/streamrip/metadata.py b/streamrip/metadata.py index a7b3a03..c95383b 100644 --- a/streamrip/metadata.py +++ b/streamrip/metadata.py @@ -98,8 +98,8 @@ class Covers: return f"Covers({covers})" -COPYRIGHT = "\u2117" -PHON_COPYRIGHT = "\u00a9" +PHON_COPYRIGHT = "\u2117" +COPYRIGHT = "\u00a9" @dataclass(slots=True) @@ -201,10 +201,13 @@ class TrackInfo: bit_depth: Optional[int] = None explicit: bool = False - sampling_rate: Optional[int] = None + sampling_rate: Optional[int | float] = None work: Optional[str] = None +genre_clean = re.compile(r"([^\u2192\/]+)") + + @dataclass(slots=True) class AlbumMetadata: info: AlbumInfo @@ -214,25 +217,36 @@ class AlbumMetadata: year: str genre: list[str] covers: Covers + tracktotal: int + disctotal: int = 1 albumcomposer: Optional[str] = None comment: Optional[str] = None compilation: Optional[str] = None copyright: Optional[str] = None date: Optional[str] = None description: Optional[str] = None - disctotal: Optional[int] = None encoder: Optional[str] = None grouping: Optional[str] = None lyrics: Optional[str] = None purchase_date: Optional[str] = None - tracktotal: Optional[int] = None + + def get_genres(self) -> str: + return ", ".join(self.genre) + + def get_copyright(self) -> str | None: + if self.copyright is None: + return None + # Add special chars + _copyright = re.sub(r"(?i)\(P\)", PHON_COPYRIGHT, self.copyright) + _copyright = re.sub(r"(?i)\(C\)", COPYRIGHT, _copyright) + return _copyright def format_folder_path(self, formatter: str) -> str: # Available keys: "albumartist", "title", "year", "bit_depth", "sampling_rate", # "id", and "albumcomposer", none_str = "Unknown" - info: dict[str, str | int] = { + info: dict[str, str | int | float] = { "albumartist": self.albumartist, "albumcomposer": self.albumcomposer or none_str, "bit_depth": self.info.bit_depth or none_str, @@ -249,13 +263,11 @@ class AlbumMetadata: album = resp.get("title", "Unknown Album") tracktotal = resp.get("tracks_count", 1) genre = resp.get("genres_list") or resp.get("genre") or [] - genres = list(set(re.findall(r"([^\u2192\/]+)", "/".join(genre)))) + genres = list(set(genre_clean.findall("/".join(genre)))) date = resp.get("release_date_original") or resp.get("release_date") year = date[:4] if date is not None else "Unknown" _copyright = resp.get("copyright", "") - _copyright = re.sub(r"(?i)\(P\)", PHON_COPYRIGHT, _copyright) - _copyright = re.sub(r"(?i)\(C\)", COPYRIGHT, _copyright) if artists := resp.get("artists"): albumartist = ", ".join(a["name"] for a in artists) @@ -358,7 +370,7 @@ class AlbumInfo: container: str label: Optional[str] = None explicit: bool = False - sampling_rate: Optional[int] = None + sampling_rate: Optional[int | float] = None bit_depth: Optional[int] = None booklets: list[dict] | None = None diff --git a/streamrip/playlist.py b/streamrip/playlist.py new file mode 100644 index 0000000..9cbe7bb --- /dev/null +++ b/streamrip/playlist.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from .media import Media, Pending + + +@dataclass(slots=True) +class Playlist(Media): + pass + + +@dataclass(slots=True) +class PendingPlaylist(Pending): + pass diff --git a/streamrip/progress.py b/streamrip/progress.py index fcb9ffd..6d99eaf 100644 --- a/streamrip/progress.py +++ b/streamrip/progress.py @@ -1,9 +1,9 @@ -from typing import Optional +from typing import Callable from click import style -from tqdm.asyncio import tqdm +from rich.progress import Progress -from .config import Config +from .console import console THEMES = { "plain": None, @@ -16,14 +16,39 @@ THEMES = { } -def get_progress_bar(config: Config, total: int, desc: Optional[str], unit="B"): - theme = THEMES[config.session.theme.progress_bar] - return tqdm( - total=total, - unit=unit, - unit_scale=True, - unit_divisor=1024, - desc=desc, - dynamic_ncols=True, - bar_format=theme, - ) +class ProgressManager: + def __init__(self): + self.started = False + self.progress = Progress(console=console) + + def get_callback(self, total: int, desc: str): + if not self.started: + self.progress.start() + self.started = True + + task = self.progress.add_task(f"[cyan]{desc}", total=total) + + def _callback(x: int): + self.progress.update(task, advance=x) + + return _callback + + def cleanup(self): + if self.started: + self.progress.stop() + + +# global instance +_p = ProgressManager() + + +def get_progress_callback( + enabled: bool, total: int, desc: str +) -> Callable[[int], None]: + if not enabled: + return lambda _: None + return _p.get_callback(total, desc) + + +def clear_progress(): + _p.cleanup() diff --git a/streamrip/prompter.py b/streamrip/prompter.py index b21bfde..89b9bc3 100644 --- a/streamrip/prompter.py +++ b/streamrip/prompter.py @@ -61,8 +61,6 @@ class QobuzPrompter(CredentialPrompter): except MissingCredentials: self._prompt_creds_and_set_session_config() - secho("Successfully logged in to Qobuz", fg="green") - def _prompt_creds_and_set_session_config(self): secho("Enter Qobuz email: ", fg="green", nl=False) email = input() diff --git a/streamrip/qobuz_client.py b/streamrip/qobuz_client.py index f0c91eb..038c7f4 100644 --- a/streamrip/qobuz_client.py +++ b/streamrip/qobuz_client.py @@ -276,9 +276,7 @@ class QobuzClient(Client): logger.debug("api_request: endpoint=%s, params=%s", epoint, params) if self.rate_limiter is not None: async with self.rate_limiter: - async with self.session.get( - url, params=params, encoding="utf-8" - ) as response: + async with self.session.get(url, params=params) as response: return response.status, await response.json() # return await self.session.get(url, params=params) async with self.session.get(url, params=params) as response: diff --git a/streamrip/semaphore.py b/streamrip/semaphore.py new file mode 100644 index 0000000..d82b650 --- /dev/null +++ b/streamrip/semaphore.py @@ -0,0 +1,41 @@ +import asyncio + +from .config import DownloadsConfig + +INF = 9999 + + +class UnlimitedSemaphore: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + +_unlimited = UnlimitedSemaphore() +_global_semaphore: None | tuple[int, asyncio.Semaphore] = None + + +def global_download_semaphore( + c: DownloadsConfig, +) -> UnlimitedSemaphore | asyncio.Semaphore: + global _unlimited, _global_semaphore + + if c.concurrency: + max_connections = c.max_connections if c.max_connections > 0 else INF + else: + max_connections = 1 + + assert max_connections > 0 + if max_connections == INF: + return _unlimited + + if _global_semaphore is None: + _global_semaphore = (max_connections, asyncio.Semaphore(max_connections)) + + assert ( + max_connections == _global_semaphore[0] + ), f"Already have other global semaphore {_global_semaphore}" + + return _global_semaphore[1] diff --git a/streamrip/tagger.py b/streamrip/tagger.py index 04f3b11..8064984 100644 --- a/streamrip/tagger.py +++ b/streamrip/tagger.py @@ -159,7 +159,7 @@ class Container(Enum): out.append((v, text)) return out - def _attr_from_meta(self, meta: TrackMetadata, attr: str) -> str: + def _attr_from_meta(self, meta: TrackMetadata, attr: str) -> str | None: # TODO: verify this works in_trackmetadata = { "title", @@ -170,9 +170,21 @@ class Container(Enum): "composer", } if attr in in_trackmetadata: - return str(getattr(meta, attr)) + if attr == "album": + return meta.album.album + val = getattr(meta, attr) + if val is None: + return None + return str(val) else: - return str(getattr(meta.album, attr)) + if attr == "genre": + return meta.album.get_genres() + elif attr == "copyright": + return meta.album.get_copyright() + val = getattr(meta.album, attr) + if val is None: + return None + return str(val) def tag_audio(self, audio, tags: list[tuple]): for k, v in tags: diff --git a/streamrip/thread_pool.py b/streamrip/thread_pool.py deleted file mode 100644 index 357570f..0000000 --- a/streamrip/thread_pool.py +++ /dev/null @@ -1,21 +0,0 @@ -import asyncio - - -class AsyncThreadPool: - """Allows a maximum of `max_workers` coroutines to be running at once.""" - - def __init__(self, max_workers: int): - self.s = asyncio.Semaphore(max_workers) - - async def gather(self, coros: list): - async def _wrapper(coro): - async with self.s: - await coro - - return await asyncio.gather(*(_wrapper(c) for c in coros)) - - async def __aenter__(self): - return self - - async def __aexit__(self, *_): - pass diff --git a/streamrip/track.py b/streamrip/track.py index 1bc9758..0a4f9f3 100644 --- a/streamrip/track.py +++ b/streamrip/track.py @@ -10,7 +10,8 @@ from .downloadable import Downloadable from .filepath_utils import clean_filename from .media import Media, Pending from .metadata import AlbumMetadata, Covers, TrackMetadata -from .progress import get_progress_bar +from .progress import get_progress_callback +from .semaphore import global_download_semaphore from .tagger import tag_file @@ -31,14 +32,13 @@ class Track(Media): async def download(self): # TODO: progress bar description - with get_progress_bar( - self.config, - await self.downloadable.size(), - f"Track {self.meta.tracknumber}", - ) as bar: - await self.downloadable.download( - self.download_path, lambda x: bar.update(x) + async with global_download_semaphore(self.config.session.downloads): + callback = get_progress_callback( + self.config.session.cli.progress_bars, + await self.downloadable.size(), + f"Track {self.meta.tracknumber}", ) + await self.downloadable.download(self.download_path, callback) async def postprocess(self): await self._tag() @@ -52,7 +52,7 @@ class Track(Media): await tag_file(self.download_path, self.meta, self.cover_path) async def _convert(self): - CONV_CLASS = { + CONV_CLASS: dict[str, type[converter.Converter]] = { "FLAC": converter.FLAC, "ALAC": converter.ALAC, "MP3": converter.LAME, @@ -67,9 +67,10 @@ class Track(Media): engine = CONV_CLASS[codec.upper()]( filename=self.download_path, sampling_rate=c.sampling_rate, + bit_depth=c.bit_depth, remove_source=True, # always going to delete the old file ) - engine.convert() + await engine.convert() self.download_path = engine.final_fn # because the extension changed def _set_download_path(self): @@ -93,6 +94,7 @@ class PendingTrack(Pending): client: Client config: Config folder: str + # cover_path is None <==> Artwork for this track doesn't exist in API cover_path: str | None async def resolve(self) -> Track: diff --git a/tests/1x1_pixel.jpg b/tests/1x1_pixel.jpg new file mode 100644 index 0000000..bead489 Binary files /dev/null and b/tests/1x1_pixel.jpg differ diff --git a/tests/qobuz_track_resp.json b/tests/qobuz_track_resp.json index 26cafb2..cea8dc4 100644 --- a/tests/qobuz_track_resp.json +++ b/tests/qobuz_track_resp.json @@ -1 +1 @@ -{"maximum_bit_depth": 16, "copyright": "\u2117 1993 UMG Recordings, Inc.", "performers": "Bob Weston, Unknown, Other - Kurt Cobain, Guitar, Vocals, AssociatedPerformer, ComposerLyricist - Krist Novoselic, Composer, Bass, AssociatedPerformer, ComposerLyricist - STEVE Albini, Producer, Mixer, Recording Engineer, StudioPersonnel - Dave Grohl, Composer, Drums, AssociatedPerformer - Nirvana, MainArtist", "audio_info": {"replaygain_track_gain": -9.16, "replaygain_track_peak": 0.979492}, "performer": {"id": 46708, "name": "Nirvana"}, "album": {"maximum_bit_depth": 16, "image": {"small": "https://static.qobuz.com/images/covers/fc/wh/y6x0dqvaswhfc_230.jpg", "thumbnail": "https://static.qobuz.com/images/covers/fc/wh/y6x0dqvaswhfc_50.jpg", "large": "https://static.qobuz.com/images/covers/fc/wh/y6x0dqvaswhfc_600.jpg", "back": null}, "media_count": 3, "artist": {"image": null, "name": "Nirvana", "id": 46708, "albums_count": 308, "slug": "nirvana", "picture": null}, "artists": [{"id": 46708, "name": "Nirvana", "roles": ["main-artist"]}], "upc": "0602458343144", "released_at": 747957600, "label": {"name": "Geffen", "id": 1123, "albums_count": 3335, "supplier_id": 1, "slug": "geffen"}, "title": "In Utero", "qobuz_id": 223973846, "version": "30th Anniversary Super Deluxe", "url": "https://www.qobuz.com/fr-fr/album/in-utero-nirvana/y6x0dqvaswhfc", "duration": 15937, "parental_warning": false, "popularity": 0, "tracks_count": 72, "genre": {"path": [112, 119], "color": "#5eabc1", "name": "Rock", "id": 119, "slug": "rock"}, "maximum_channel_count": 2, "id": "y6x0dqvaswhfc", "maximum_sampling_rate": 44.1, "articles": [], "release_date_original": "1993-09-14", "release_date_download": "1993-09-14", "release_date_stream": "1993-09-14", "purchasable": true, "streamable": true, "previewable": true, "sampleable": true, "downloadable": true, "displayable": true, "purchasable_at": 1698390000, "streamable_at": 1698390000, "hires": false, "hires_streamable": false, "awards": [], "description": "

After the 20th anniversary edition of In Utero seemed to empty the vaults by including not just a remastered version of the original album as released, but also discarded track mixes by Scott Litt and Steve Albini, as well as the latter\u2019s \"from a different angle\" mix of the entire album, it's hard to imagine what sonic treasures could be packed into a new version a decade later. And while many fans will head straight for the pair of full-set live shows that make up the bulk of this edition's tracklisting, one would be ill-advised to sleep on the version of the actual album that's included here. Yes, it's yet another remaster, but in this case the work has been done by Bob Weston, who not only brings decades of experience recording, mixing, and mastering your favorite indie rock bands (everyone from Sebadoh and Polvo to Chavez and Archers of Loaf), but he was also Albini's assistant engineer during the In Utero sessions. Even more notably for this remaster, Weston was able to work with the original analog master tapes, resulting in an exceptional presentation of an album that has always seemed too brawny and bristly to fit into any standard musical delivery device. Weston brings a full-bodied warmth to the material that does nothing to tamp down the splenetic intensity of this intentionally abrasive album, but somehow gives it even more emotional impact. Similarly, Seattle studio legend Jack Endino (who produced Bleach) was brought on to mix and master the live material from soundboard tapes. While Weston was able to harness the crackling warmth of the album tracks, Endino goes straight for maximum impact on the live material, giving the two shows\u2014one in L.A. at the beginning of the In Utero tour, and the other in Seattle for the band's final show in that city\u2014an absolutely explosive presence. The well-rounded mix of the live material gives the performances plenty of dynamics and a surprising amount of clarity, whether it's the plinky-plonky intro of \"Milk It\" in Seattle giving way to bass-heavy riffing or the clanging garage groove of \"About a Girl\" in Los Angeles. While it's a fool's errand to deem a release such as this as a \"definitive\" one, the combination of Weston's remarkable remaster with two incredible-sounding concerts from the era (plus all the released b-sides from the album) makes a strong case for this edition being exactly that ... at least until the next big anniversary. \u00a9 Jason Ferguson/Qobuz

", "description_language": "en", "goodies": [], "area": null, "catchline": "", "composer": {"id": 573076, "name": "Various Composers", "slug": "various-composers", "albums_count": 583621, "picture": null, "image": null}, "created_at": 0, "genres_list": ["Pop/Rock", "Pop/Rock\u2192Rock"], "period": null, "copyright": "\u00a9 2023 UMG Recordings, Inc. \u2117 2023 UMG Recordings, Inc.", "is_official": true, "maximum_technical_specifications": "", "product_sales_factors_monthly": 0, "product_sales_factors_weekly": 0, "product_sales_factors_yearly": 0, "product_type": "album", "product_url": "/fr-fr/album/in-utero-nirvana/y6x0dqvaswhfc", "recording_information": "", "relative_url": "/album/in-utero-nirvana/y6x0dqvaswhfc", "release_tags": ["deluxe"], "release_type": "album", "slug": "in-utero-nirvana", "subtitle": "Nirvana"}, "work": null, "composer": {"id": 129081, "name": "Kurt Cobain"}, "isrc": "USGF19960702", "title": "Scentless Apprentice", "version": "2023 Remaster", "duration": 227, "parental_warning": false, "track_number": 2, "maximum_channel_count": 2, "id": 223973848, "media_number": 1, "maximum_sampling_rate": 44.1, "articles": [], "release_date_original": null, "release_date_download": null, "release_date_stream": null, "release_date_purchase": null, "purchasable": true, "streamable": true, "previewable": true, "sampleable": true, "downloadable": true, "displayable": true, "purchasable_at": 1698390000, "streamable_at": 1698390000, "hires": false, "hires_streamable": false} \ No newline at end of file +{"maximum_bit_depth": 24, "copyright": "2023 Merge Records 2023 Merge Records", "performers": "Trina Shoemaker, Producer - The Mountain Goats, MainArtist - John Darnielle, Composer, Lyricist - Cadmean Dawn (ASCAP) administered by Me Gusta Music, MusicPublisher", "audio_info": {"replaygain_track_gain": -7.44, "replaygain_track_peak": 0.928436}, "performer": {"id": 384672, "name": "The Mountain Goats"}, "album": {"maximum_bit_depth": 24, "image": {"small": "https://static.qobuz.com/images/covers/wa/vp/s72ps0jshvpwa_230.jpg", "thumbnail": "https://static.qobuz.com/images/covers/wa/vp/s72ps0jshvpwa_50.jpg", "large": "https://static.qobuz.com/images/covers/wa/vp/s72ps0jshvpwa_600.jpg", "back": null}, "media_count": 1, "artist": {"image": null, "name": "The Mountain Goats", "id": 384672, "albums_count": 82, "slug": "the-mountain-goats", "picture": null}, "artists": [{"id": 384672, "name": "The Mountain Goats", "roles": ["main-artist"]}], "upc": "0673855084121", "released_at": 1698357600, "label": {"name": "Merge Records", "id": 1078765, "albums_count": 980, "supplier_id": 111, "slug": "merge-records"}, "title": "Jenny from Thebes", "qobuz_id": 216020855, "version": null, "url": "https://www.qobuz.com/fr-fr/album/jenny-from-thebes-the-mountain-goats/s72ps0jshvpwa", "duration": 2383, "parental_warning": false, "popularity": 0, "tracks_count": 12, "genre": {"path": [112, 119], "color": "#5eabc1", "name": "Rock", "id": 119, "slug": "rock"}, "maximum_channel_count": 2, "id": "s72ps0jshvpwa", "maximum_sampling_rate": 96, "articles": [], "release_date_original": "2023-10-27", "release_date_download": "2023-10-27", "release_date_stream": "2023-10-27", "purchasable": true, "streamable": true, "previewable": true, "sampleable": true, "downloadable": true, "displayable": true, "purchasable_at": 1698390000, "streamable_at": 1698390000, "hires": true, "hires_streamable": true, "awards": [], "description": "

Blending the worlds of fiction, poetry and songwriting, John Darnielle is that rare creative engine who sprouts more ideas and grows more intense the longer he runs. Leading the Mountain Goats since the mid-1990s, Darnielle has developed a second career as a much-praised novelist, adept at creating lasting characters like Jenny who first appeared in her titular track on the 2002 Mountain Goats album, All Hail West Texas. Here she's been elevated to a figure from ancient mythology due to her penchant for caring for a house full of strangers looking to find themselves. Sadly, her charity has become a burden and made her, in Darnielle's words, \"someone on the verge of an unimaginable tragedy whose signs and portents will not make themselves known to her until she finds herself amidst the wreckage.\" Eventually, Jenny cracks and flees. It's a narrative you can follow or ignore because both music and lyrics are superb.

Produced by Trina Shoemaker, and tracked at The Church Studio in Tulsa, Oklahoma, the band once known for lo-fi recordings done on a boombox presents an elaborate production with admirably clear and logical sound.\u00a0 Vocals are pushed forward and horns are given a prominent place in the mix set to snappy, upbeat tempos, played to perfection by bandmates bassist Peter Hughes, multi-instrumentalist Matt Douglas, and drummer Jon Wurster; the well-oiled machine is as nimble as their rare animal namesake.

The foursome rock out hard in \"Murder at the 18th St. Garage.\" \"Only One Way\" is the kind of hooky guitar pop that's fast becoming extinct. Douglas' saxophone and trombone by guest Evan Ringel add a happy edge and a triumphant turn to \"Fresh Tattoo.\" Set to a brisk rhythm, \"Cleaning Crew\" opens with the same descending chords as The Who's \"Baba O'Riley\" but then turns funky thanks once again to horn snorts. Like most of the music here, it also contains Darnielle's spry wordcraft: \"I saw the future in an oil slick/ It told me what I needed to know/ Leave a little stain behind/ Everywhere you go.\" Thirty years in, a compulsive creator outdoes himself.\u00a0 \u00a9 Robert Baird/Qobuz

", "description_language": "en", "goodies": [], "area": null, "catchline": "", "composer": {"id": 334487, "name": "John Darnielle", "slug": "john-darnielle", "albums_count": 72, "picture": null, "image": null}, "created_at": 0, "genres_list": ["Pop/Rock", "Pop/Rock\u2192Rock"], "period": null, "copyright": "2023 Merge Records 2023 Merge Records", "is_official": true, "maximum_technical_specifications": "24 bits / 96.0 kHz - Stereo", "product_sales_factors_monthly": 0, "product_sales_factors_weekly": 0, "product_sales_factors_yearly": 0, "product_type": "album", "product_url": "/fr-fr/album/jenny-from-thebes-the-mountain-goats/s72ps0jshvpwa", "recording_information": "", "relative_url": "/album/jenny-from-thebes-the-mountain-goats/s72ps0jshvpwa", "release_tags": [], "release_type": "album", "slug": "jenny-from-thebes-the-mountain-goats", "subtitle": "The Mountain Goats"}, "work": null, "composer": {"id": 334487, "name": "John Darnielle"}, "isrc": "USMRG2384105", "title": "Cleaning Crew", "version": null, "duration": 216, "parental_warning": false, "track_number": 5, "maximum_channel_count": 2, "id": 216020860, "media_number": 1, "maximum_sampling_rate": 96, "articles": [], "release_date_original": null, "release_date_download": null, "release_date_stream": null, "release_date_purchase": null, "purchasable": true, "streamable": true, "previewable": true, "sampleable": true, "downloadable": true, "displayable": true, "purchasable_at": 1698390000, "streamable_at": 1698390000, "hires": true, "hires_streamable": true} \ No newline at end of file diff --git a/tests/test_tagger.py b/tests/test_tagger.py new file mode 100644 index 0000000..756a49b --- /dev/null +++ b/tests/test_tagger.py @@ -0,0 +1,97 @@ +import pytest +from mutagen.flac import FLAC +from util import arun + +from streamrip.metadata import * +from streamrip.tagger import tag_file + +test_flac = "tests/silence.flac" +test_cover = "tests/1x1_pixel.jpg" + + +def wipe_test_flac(): + audio = FLAC(test_flac) + # Remove all tags + audio.delete() + audio.save() + + +@pytest.fixture +def sample_metadata() -> TrackMetadata: + return TrackMetadata( + TrackInfo( + id="12345", + quality=3, + bit_depth=24, + explicit=True, + sampling_rate=96, + work=None, + ), + "testtitle", + AlbumMetadata( + AlbumInfo("5678", 4, "flac"), + "testalbum", + "testalbumartist", + "1999", + ["rock", "pop"], + Covers(), + 14, + 3, + "testalbumcomposer", + "testcomment", + compilation="testcompilation", + copyright="(c) stuff (p) other stuff", + date="1998-02-13", + description="testdesc", + encoder="ffmpeg", + grouping="testgroup", + lyrics="ye ye ye", + purchase_date=None, + ), + "testartist", + 3, + 1, + "testcomposer", + ) + + +def test_tag_flac_no_cover(sample_metadata): + wipe_test_flac() + arun(tag_file(test_flac, sample_metadata, None)) + file = FLAC(test_flac) + assert file["title"][0] == "testtitle" + assert file["album"][0] == "testalbum" + assert file["composer"][0] == "testcomposer" + assert file["comment"][0] == "testcomment" + assert file["artist"][0] == "testartist" + assert file["albumartist"][0] == "testalbumartist" + assert file["year"][0] == "1999" + assert file["genre"][0] == "rock, pop" + assert file["tracknumber"][0] == "03" + assert file["discnumber"][0] == "01" + assert file["copyright"][0] == "© stuff ℗ other stuff" + assert file["tracktotal"][0] == "14" + assert file["date"][0] == "1998-02-13" + assert "purchase_date" not in file, file["purchase_date"] + + +def test_tag_flac_cover(sample_metadata): + wipe_test_flac() + arun(tag_file(test_flac, sample_metadata, test_cover)) + file = FLAC(test_flac) + assert file["title"][0] == "testtitle" + assert file["album"][0] == "testalbum" + assert file["composer"][0] == "testcomposer" + assert file["comment"][0] == "testcomment" + assert file["artist"][0] == "testartist" + assert file["albumartist"][0] == "testalbumartist" + assert file["year"][0] == "1999" + assert file["genre"][0] == "rock, pop" + assert file["tracknumber"][0] == "03" + assert file["discnumber"][0] == "01" + assert file["copyright"][0] == "© stuff ℗ other stuff" + assert file["tracktotal"][0] == "14" + assert file["date"][0] == "1998-02-13" + with open(test_cover, "rb") as img: + assert file.pictures[0].data == img.read() + assert "purchase_date" not in file, file["purchase_date"]