- New src/artwork/ module with iPod Nano 7G cover art support - codecs: RGB565/RGB555/UYVY/JPEG encode/decode via numpy - presets: 50+ iPod artwork format definitions - chunks: ArtworkDB binary parser/writer (MHFD→MHSD→MHLI→MHII→MHNI) - writer: ithmb file generation + ArtworkDB assembly - extractor: cover extraction from audio files (APIC/covr/FLAC pictures) - cache: local JPEG cache for player UI - iTunes-style playback bar with 60x60 cover art display - Scan library now caches cover art by artist+album - iPod Nano7 transfer writes artwork to ArtworkDB + ithmb - FFmpeg conversion preserves embedded cover art (-map 0:t?) - One-time reembed_covers.py script in /tmp - MetadataHandler process_file() fallback to cover_path - Bug fixes: missing read_existing_artwork import, signed PID in Q format
161 lines
4.4 KiB
Python
161 lines
4.4 KiB
Python
"""Extract embedded album art from media files using mutagen.
|
|
|
|
Ported from iOpenPod ArtworkDB_Writer/art_extractor.py.
|
|
Supports: MP3, M4A/AAC, M4B, M4V/MP4, FLAC, OGG, OPUS, AIFF/WAV.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import mutagen
|
|
|
|
|
|
def extract_cover(file_path: str) -> bytes | None:
|
|
"""Extract the first embedded album art image from a media file.
|
|
|
|
Returns raw image bytes (JPEG/PNG) or None.
|
|
"""
|
|
path = Path(file_path)
|
|
ext = path.suffix.lower()
|
|
|
|
try:
|
|
if ext in _IMAGE_EXTS:
|
|
return path.read_bytes()
|
|
if ext == ".mp3":
|
|
return _extract_mp3(file_path)
|
|
elif ext in (".m4a", ".m4p", ".m4b", ".aac", ".alac"):
|
|
return _extract_mp4(file_path)
|
|
elif ext in (".m4v", ".mp4", ".mov"):
|
|
art = _extract_mp4(file_path)
|
|
if art:
|
|
return art
|
|
return None
|
|
elif ext == ".flac":
|
|
return _extract_flac(file_path)
|
|
elif ext == ".ogg":
|
|
return _extract_ogg(file_path)
|
|
elif ext == ".opus":
|
|
return _extract_opus(file_path)
|
|
elif ext in (".aif", ".aiff"):
|
|
return _extract_aiff(file_path)
|
|
else:
|
|
return _extract_generic(file_path)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def find_folder_art(file_path: str) -> str | None:
|
|
"""Return path of folder artwork image next to *file_path*, or None."""
|
|
directory = str(Path(file_path).parent)
|
|
try:
|
|
entries = os.listdir(directory)
|
|
except OSError:
|
|
return None
|
|
lower_map = {e.lower(): e for e in entries}
|
|
for stem in _FOLDER_ART_NAMES:
|
|
for ext in _IMAGE_EXTS:
|
|
if (stem + ext) in lower_map:
|
|
return os.path.join(directory, lower_map[stem + ext])
|
|
return None
|
|
|
|
|
|
def extract_cover_with_fallback(file_path: str) -> bytes | None:
|
|
"""Extract cover art, falling back to folder.jpg if no embedded art."""
|
|
art = extract_cover(file_path)
|
|
if art is not None:
|
|
return art
|
|
folder_img = find_folder_art(file_path)
|
|
if folder_img:
|
|
try:
|
|
return Path(folder_img).read_bytes()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
_FOLDER_ART_NAMES = ("cover", "folder", "album", "front", "artwork", "thumb")
|
|
_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp")
|
|
|
|
|
|
def _extract_mp3(path: str) -> bytes | None:
|
|
from mutagen.mp3 import MP3
|
|
audio = MP3(path)
|
|
if audio.tags is None:
|
|
return None
|
|
for key in audio.tags:
|
|
if key.startswith("APIC"):
|
|
return audio.tags[key].data
|
|
return None
|
|
|
|
|
|
def _extract_mp4(path: str) -> bytes | None:
|
|
from mutagen.mp4 import MP4
|
|
audio = MP4(path)
|
|
if audio.tags is None:
|
|
return None
|
|
covers = audio.tags.get("covr", [])
|
|
if covers:
|
|
return bytes(covers[0])
|
|
return None
|
|
|
|
|
|
def _extract_flac(path: str) -> bytes | None:
|
|
from mutagen.flac import FLAC
|
|
audio = FLAC(path)
|
|
if audio.pictures:
|
|
return audio.pictures[0].data
|
|
return None
|
|
|
|
|
|
def _extract_ogg(path: str) -> bytes | None:
|
|
from mutagen.oggvorbis import OggVorbis
|
|
audio = OggVorbis(path)
|
|
return _extract_vorbis_picture(audio)
|
|
|
|
|
|
def _extract_opus(path: str) -> bytes | None:
|
|
from mutagen.oggopus import OggOpus
|
|
audio = OggOpus(path)
|
|
return _extract_vorbis_picture(audio)
|
|
|
|
|
|
def _extract_vorbis_picture(audio) -> bytes | None:
|
|
import base64
|
|
pictures = audio.get("metadata_block_picture", [])
|
|
if pictures:
|
|
try:
|
|
from mutagen.flac import Picture
|
|
pic = Picture(base64.b64decode(pictures[0]))
|
|
return pic.data
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _extract_aiff(path: str) -> bytes | None:
|
|
from mutagen.aiff import AIFF
|
|
audio = AIFF(path)
|
|
if audio.tags is None:
|
|
return None
|
|
for key in audio.tags:
|
|
if key.startswith("APIC"):
|
|
return audio.tags[key].data
|
|
return None
|
|
|
|
|
|
def _extract_generic(path: str) -> bytes | None:
|
|
audio = mutagen.File(path)
|
|
if audio is None or audio.tags is None:
|
|
return None
|
|
for key in audio.tags:
|
|
if hasattr(key, "startswith") and key.startswith("APIC"):
|
|
frame = audio.tags[key]
|
|
if hasattr(frame, "data"):
|
|
return frame.data
|
|
covers = audio.tags.get("covr", [])
|
|
if covers:
|
|
return bytes(covers[0])
|
|
return None
|