Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion streamrip/converter.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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",
Expand All @@ -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())
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down