diff --git a/streamrip/config.py b/streamrip/config.py index bafa5687..ee1ea39d 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -106,7 +106,7 @@ class DatabaseConfig: @dataclass(slots=True) class ConversionConfig: enabled: bool - # FLAC, ALAC, OPUS, MP3, VORBIS, or AAC + # FLAC, ALAC, AIFF, OPUS, MP3, VORBIS, or AAC codec: str # In Hz. Tracks are downsampled if their sampling rate is greater than this. # Value of 48000 is recommended to maximize quality and minimize space diff --git a/streamrip/config.toml b/streamrip/config.toml index 029115bd..51ccac15 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -103,7 +103,7 @@ failed_downloads_path = "" # Convert tracks to a codec after downloading them. [conversion] enabled = false -# FLAC, ALAC, OPUS, MP3, VORBIS, or AAC +# FLAC, ALAC, AIFF, OPUS, MP3, VORBIS, or AAC codec = "ALAC" # In Hz. Tracks are downsampled if their sampling rate is greater than this. # Value of 48000 is recommended to maximize quality and minimize space diff --git a/streamrip/converter.py b/streamrip/converter.py index aa4aa2aa..28c1a465 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -177,6 +177,15 @@ class FLAC(Converter): lossless = True +class AIFF(Converter): + """Class for AIFF converter (lossless PCM).""" + + codec_name = "aiff" + codec_lib = "pcm_s24be" + container = "aiff" + lossless = True + + class LAME(Converter): """Class for libmp3lame converter. @@ -288,5 +297,6 @@ def get(codec: str) -> type[Converter]: "VORBIS": Vorbis, "AAC": AAC, "M4A": AAC, + "AIFF": AIFF, } return converter_classes[codec.upper()] diff --git a/streamrip/media/track.py b/streamrip/media/track.py index bc3207d5..e4fd34a1 100644 --- a/streamrip/media/track.py +++ b/streamrip/media/track.py @@ -93,6 +93,13 @@ async def _convert(self): ) await engine.convert() self.download_path = engine.final_fn # because the extension changed + # Re-tag the converted file: ffmpeg does not reliably carry over all + # metadata (and some containers, e.g. AIFF, are not tagged at all). + # Only formats that tag_file understands are re-tagged; others (opus, + # ogg, ...) keep the metadata ffmpeg copied during conversion. + ext = self.download_path.split(".")[-1].lower() + if ext in ("flac", "m4a", "mp3", "aiff", "aif"): + await tag_file(self.download_path, self.meta, self.cover_path) def _set_download_path(self): c = self.config.session.filepaths diff --git a/streamrip/metadata/tagger.py b/streamrip/metadata/tagger.py index aae8e2b5..36c73a20 100644 --- a/streamrip/metadata/tagger.py +++ b/streamrip/metadata/tagger.py @@ -4,6 +4,7 @@ import aiofiles from mutagen import id3 +from mutagen.aiff import AIFF from mutagen.flac import FLAC, Picture from mutagen.id3 import ( APIC, # type: ignore @@ -100,12 +101,18 @@ class Container(Enum): FLAC = 1 AAC = 2 MP3 = 3 + AIFF = 4 def get_mutagen_class(self, path: str): if self == Container.FLAC: return FLAC(path) elif self == Container.AAC: return MP4(path) + elif self == Container.AIFF: + audio = AIFF(path) + if audio.tags is None: + audio.add_tags() + return audio.tags elif self == Container.MP3: try: return ID3(path) @@ -117,7 +124,7 @@ def get_mutagen_class(self, path: str): def get_tag_pairs(self, meta) -> list[tuple]: if self == Container.FLAC: return self._tag_flac(meta) - elif self == Container.MP3: + elif self == Container.MP3 or self == Container.AIFF: return self._tag_mp3(meta) elif self == Container.AAC: return self._tag_mp4(meta) @@ -217,7 +224,7 @@ async def embed_cover(self, audio, cover_path): async with aiofiles.open(cover_path, "rb") as img: cover.data = await img.read() audio.add_picture(cover) - elif self == Container.MP3: + elif self == Container.MP3 or self == Container.AIFF: cover = APIC() cover.type = 3 cover.mime = "image/jpeg" @@ -236,6 +243,10 @@ def save_audio(self, audio, path): audio.save() elif self == Container.MP3: audio.save(path, "v2_version=3") + elif self == Container.AIFF: + # AIFF uses an _IFFID3 tag object whose save() signature differs + # from MP3's ID3.save(); call it with defaults (v2_version=4). + audio.save(path) async def tag_file(path: str, meta: TrackMetadata, cover_path: str | None): @@ -246,6 +257,8 @@ async def tag_file(path: str, meta: TrackMetadata, cover_path: str | None): container = Container.AAC elif ext == "mp3": container = Container.MP3 + elif ext in ("aiff", "aif"): + container = Container.AIFF else: raise Exception(f"Invalid extension {ext}") diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index 2b3f532e..ee2bac41 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -65,7 +65,7 @@ def wrapper(*args, **kwargs): @click.option( "-c", "--codec", - help="Convert the downloaded files to an audio codec (ALAC, FLAC, MP3, AAC, or OGG)", + help="Convert the downloaded files to an audio codec (ALAC, FLAC, AIFF, MP3, AAC, or OGG)", ) @click.option( "--no-progress", @@ -152,7 +152,7 @@ def rip( if codec is not None: c.session.conversion.enabled = True - assert codec.upper() in ("ALAC", "FLAC", "OGG", "MP3", "AAC") + assert codec.upper() in ("ALAC", "FLAC", "AIFF", "OGG", "MP3", "AAC") c.session.conversion.codec = codec.upper() if no_progress: