From 9d124e46e103c0a4d08f0b90b8a5d2f4475f4dd9 Mon Sep 17 00:00:00 2001 From: katayanagi Date: Wed, 24 Jun 2026 16:33:44 +0900 Subject: [PATCH 1/2] Add AIFF codec support for conversion Add an AIFF converter (lossless PCM, pcm_s24be) so downloads can be converted to .aiff via the `[conversion]` codec option or `--codec aiff`. Co-Authored-By: Claude Opus 4.8 --- streamrip/config.py | 2 +- streamrip/config.toml | 2 +- streamrip/converter.py | 10 ++++++++++ streamrip/rip/cli.py | 4 ++-- 4 files changed, 14 insertions(+), 4 deletions(-) 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/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: From 196b47b7eff89abfa196d3134c2cc95b63df0ee7 Mon Sep 17 00:00:00 2001 From: katayanagi Date: Wed, 24 Jun 2026 16:53:30 +0900 Subject: [PATCH 2/2] Tag AIFF (and re-tag converted files) with full metadata Conversion runs after tagging the source file, but ffmpeg does not reliably carry metadata into the converted container (AIFF was left completely untagged). Add an AIFF tagging path (ID3 via mutagen) and re-tag the converted file for formats tag_file understands. Co-Authored-By: Claude Opus 4.8 --- streamrip/media/track.py | 7 +++++++ streamrip/metadata/tagger.py | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) 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}")