From c12dd9305352129612f004473b620c2fc91657a5 Mon Sep 17 00:00:00 2001 From: berettavexee Date: Sun, 14 Jun 2026 22:09:24 +0200 Subject: [PATCH] fix(converter): properly handle cover art for OGG and OPUS output The OGG and Opus muxers do not support -c:v copy when the source file contains an embedded cover art video stream (e.g. a tagged FLAC). This caused FFmpeg to either fail outright or silently transcode the cover art to Theora, producing an unexpected video stream in the output. This commit fixes the issue with a two-part approach: 1. A new class attribute `_ffmpeg_supports_art` (True by default) lets subclasses signal that their muxer cannot handle video streams. Vorbis and OPUS set it to False. The `_gen_command()` method then: - skips `-c:v copy` for these formats - adds `-vn` to explicitly discard any video stream from the input 2. Cover art is instead embedded post-conversion via mutagen using the METADATA_BLOCK_PICTURE Vorbis comment tag, which both OGG Vorbis and Opus containers support natively. The art is read from the source file *before* conversion starts so it remains available even when `remove_source=True` causes the source to be deleted mid-process. No behaviour change for FLAC, MP3, ALAC, or AAC converters. Co-Authored-By: Claude Sonnet 4.6 --- streamrip/converter.py | 70 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/streamrip/converter.py b/streamrip/converter.py index aa4aa2aa..6d791c7f 100644 --- a/streamrip/converter.py +++ b/streamrip/converter.py @@ -1,9 +1,11 @@ """Wrapper classes over FFMPEG.""" import asyncio +import base64 import logging import os import shutil +import subprocess from tempfile import gettempdir from typing import Final, Optional @@ -22,6 +24,9 @@ class Converter: container: str lossless: bool = False default_ffmpeg_arg: str = "" + # Subclasses set this to False when the muxer doesn't accept -c:v copy. + # Art is then embedded post-conversion via mutagen instead. + _ffmpeg_supports_art: bool = True def __init__( self, @@ -81,6 +86,13 @@ async def convert(self, custom_fn: Optional[str] = None): if custom_fn: self.final_fn = custom_fn + # Read cover art from the source before FFmpeg runs and before any + # potential source deletion, so it's available for post-conversion + # embedding even when remove_source=True. + cover_data: tuple[bytes, str] | None = None + if self.copy_art and not type(self)._ffmpeg_supports_art: + cover_data = await asyncio.to_thread(self._read_source_cover) + self.command = self._gen_command() logger.debug("Generated conversion command: %s", self.command) @@ -96,9 +108,59 @@ async def convert(self, custom_fn: Optional[str] = None): shutil.move(self.tempfile, self.final_fn) logger.debug("Moved: %s -> %s", self.tempfile, self.final_fn) + + if cover_data is not None: + await asyncio.to_thread(self._embed_cover_art, *cover_data) else: raise ConversionError(f"FFmpeg output:\n{out, err}") + def _read_source_cover(self) -> tuple[bytes, str] | None: + """Read cover art from the source file. Returns (data, mime) or None.""" + from mutagen.flac import FLAC + from mutagen.id3 import ID3 + + src_ext = os.path.splitext(self.filename)[1].lower() + try: + if src_ext == ".flac": + src = FLAC(self.filename) + if src.pictures: + p = src.pictures[0] + return p.data, p.mime + elif src_ext == ".mp3": + tags = ID3(self.filename) + apic_list = tags.getall("APIC") + if apic_list: + return apic_list[0].data, apic_list[0].mime + except Exception as e: + logger.debug("Could not read cover art from source: %s", e) + return None + + def _embed_cover_art(self, cover_data: bytes, mime: str) -> None: + """Embed cover art into the converted OGG/OPUS file via METADATA_BLOCK_PICTURE.""" + from mutagen.flac import Picture + + pic = Picture() + pic.type = 3 # front cover + pic.mime = mime + pic.data = cover_data + encoded = base64.b64encode(pic.write()).decode("ascii") + + out_ext = os.path.splitext(self.final_fn)[1].lower() + try: + if out_ext == ".ogg": + from mutagen.oggvorbis import OggVorbis + audio = OggVorbis(self.final_fn) + elif out_ext == ".opus": + from mutagen.oggopus import OggOpus + audio = OggOpus(self.final_fn) + else: + return + audio["metadata_block_picture"] = [encoded] + audio.save() + logger.debug("Embedded cover art via mutagen into %s", self.final_fn) + except Exception as e: + logger.warning("Could not embed cover art into %s: %s", self.final_fn, e) + def _gen_command(self): command = [ "ffmpeg", @@ -114,8 +176,10 @@ def _gen_command(self): if self.show_progress: command.append("-stats") - if self.copy_art: + if self.copy_art and type(self)._ffmpeg_supports_art: command.extend(["-c:v", "copy"]) + elif not type(self)._ffmpeg_supports_art: + command.append("-vn") if self.ffmpeg_arg: command.extend(self.ffmpeg_arg.split()) @@ -230,6 +294,8 @@ class Vorbis(Converter): codec_name = "vorbis" codec_lib = "libvorbis" container = "ogg" + # The OGG muxer doesn't support -c:v copy; art is embedded via mutagen instead. + _ffmpeg_supports_art = False default_ffmpeg_arg = "-q:a 6" # 160, aka the "high" quality profile from Spotify def get_quality_arg(self, rate: int) -> str: @@ -254,6 +320,8 @@ class OPUS(Converter): codec_name = "opus" codec_lib = "libopus" container = "opus" + # The Opus muxer doesn't support -c:v copy; art is embedded via mutagen instead. + _ffmpeg_supports_art = False default_ffmpeg_arg = "-b:a 128k" # Transparent def get_quality_arg(self, _: int) -> str: