"""Apply album art to existing library files. Two jobs, both reusing the post-processing standard so the user's ``album_art_order`` preference is honored and embedded art matches cover.jpg: - Detect whether an album already has art ON DISK (embedded in the audio file or a cover.jpg/folder.jpg sidecar) — the Cover Art Filler previously only looked at the DB ``thumb_url``, so albums whose files were artless but whose DB row had a URL were never flagged. - Embed found art into the album's audio files (``embed_album_art_metadata``) and write a cover.jpg (``download_cover_art``). Only ADDS art — it does not clear or rewrite the user's existing tags. """ from __future__ import annotations import contextlib import errno import os from typing import Iterable, Optional from core.metadata.artwork import download_cover_art, embed_album_art_metadata from core.metadata.common import get_mutagen_symbols from utils.logging_config import get_logger logger = get_logger("metadata.art_apply") # Folder-level cover files recognised across players (matches soulsync_client). _COVER_SIDECARS = ( "cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.jpeg", "folder.png", ) def folder_has_cover_sidecar(folder: str) -> bool: """True if the album folder already carries a cover.jpg/folder.jpg sidecar.""" if not folder: return False try: for name in _COVER_SIDECARS: if os.path.isfile(os.path.join(folder, name)): return True except OSError: return False return False def file_has_embedded_art(file_path: str) -> bool: """True if the audio file already has embedded cover art (FLAC picture, ID3 APIC, MP4 covr, or a Vorbis metadata_block_picture).""" if not file_path or not os.path.isfile(file_path): return False symbols = get_mutagen_symbols() if not symbols: return False try: return _audio_has_art(symbols.File(file_path), symbols) except Exception as exc: logger.debug("art presence check failed for %s: %s", file_path, exc) return False def _audio_has_art(audio, symbols) -> bool: """True if an already-open mutagen object carries embedded cover art.""" if audio is None: return False # FLAC / Ogg expose picture blocks directly. if getattr(audio, "pictures", None): return True if isinstance(audio, symbols.MP4): return bool(audio.get("covr")) tags = getattr(audio, "tags", None) if tags is None: return False with contextlib.suppress(Exception): if isinstance(tags, symbols.ID3): return bool(tags.getall("APIC")) with contextlib.suppress(Exception): if "metadata_block_picture" in tags: return True return False def extract_embedded_art(file_path: str) -> Optional[bytes]: """Return the first embedded cover-art image bytes from an audio file, or None. Used to write a cover.jpg sidecar from the album's OWN art — no API call, and the sidecar matches what's embedded (#813/Sokhi).""" if not file_path or not os.path.isfile(file_path): return None symbols = get_mutagen_symbols() if not symbols: return None try: audio = symbols.File(file_path) if audio is None: return None pics = getattr(audio, "pictures", None) # FLAC / Ogg if pics: return bytes(pics[0].data) if isinstance(audio, symbols.MP4): covr = audio.get("covr") if covr: return bytes(covr[0]) tags = getattr(audio, "tags", None) if tags is not None: with contextlib.suppress(Exception): if isinstance(tags, symbols.ID3): apics = tags.getall("APIC") if apics: return bytes(apics[0].data) except Exception as exc: logger.debug("embedded-art extract failed for %s: %s", file_path, exc) return None def album_has_art_on_disk(rep_file_path: str) -> bool: """Does this album have art on disk? Checks the folder for a cover sidecar first (cheap stat) and only opens the representative audio file when there's no sidecar. Returns True when there's no local file to inspect (e.g. a media-server-only album) so such albums aren't wrongly flagged as missing file art. """ if not rep_file_path: return True folder = os.path.dirname(rep_file_path) if folder_has_cover_sidecar(folder): return True return file_has_embedded_art(rep_file_path) def apply_art_to_album_files( file_paths: Iterable[str], metadata: dict, album_info: dict, folder: str = None, context: dict = None, ) -> dict: """Embed art into each audio file + write cover.jpg, reusing the standard. ``metadata`` feeds ``embed_album_art_metadata`` (needs album_artist/artist/ album, optionally musicbrainz_release_id and album_art_url as the fallback URL). ``album_info`` feeds ``download_cover_art`` (album_name/album_image_url/ musicbrainz_release_id). Existing tags are preserved — only art is added. Returns counts; never raises (unwritable/read-only files are skipped). ``read_only_fs`` is True when the target filesystem itself rejects writes (a real EROFS from an actual write — a ':ro' volume, a read-only host/NFS/ SMB mount, or a read-only underlying fs) so callers can tell the user the real cause instead of a generic failure. NOTE: read-only is detected from an ACTUAL write raising EROFS, never from statvfs/mount flags — union/FUSE/network filesystems (mergerfs, rclone, NFS) common in self-hosted setups misreport those flags, which would false-block a perfectly writable library (Sokhi: read-only error with no ':ro' in compose). The write itself is the only honest test. """ result = {"embedded": 0, "failed": 0, "skipped": 0, "cover_written": False, "read_only_fs": False} symbols = get_mutagen_symbols() paths = [p for p in (file_paths or []) if p] if not symbols: return result for fp in paths: if not os.path.isfile(fp): result["skipped"] += 1 continue try: audio = symbols.File(fp) if audio is None: result["skipped"] += 1 continue # Purely additive: never touch a file that already has art. Embedding # again would APPEND a duplicate picture on FLAC (add_picture doesn't # replace), so leave already-arted files alone. if _audio_has_art(audio, symbols): result["skipped"] += 1 continue # ID3 needs a tag container before APIC can be added. if getattr(audio, "tags", None) is None and hasattr(audio, "add_tags"): with contextlib.suppress(Exception): audio.add_tags() if embed_album_art_metadata(audio, metadata): audio.save() result["embedded"] += 1 else: result["failed"] += 1 except Exception as exc: # Read-only mounts / permission errors land here — skip, don't crash. # A real EROFS = the mount is read-only; flag it and stop trying the # rest (fast-fail without the unreliable statvfs guess). if getattr(exc, "errno", None) == errno.EROFS: result["read_only_fs"] = True logger.warning("Could not embed art into %s: read-only filesystem", fp) result["failed"] += len(paths) - paths.index(fp) # remaining all fail too break logger.warning("Could not embed art into %s: %s", fp, exc) result["failed"] += 1 # Prefer the caller's folder, but if it doesn't actually exist (e.g. a raw # DB path that isn't mounted in this container), fall back to the real # directory of the files we just wrote to — never silently skip the sidecar # because a passed-in folder was wrong (Sokhi: cover.jpg never written). target_dir = folder if (folder and os.path.isdir(folder)) else None if not target_dir and paths: cand = os.path.dirname(paths[0]) target_dir = cand if os.path.isdir(cand) else None if target_dir and not folder_has_cover_sidecar(target_dir): # Prefer the album's OWN embedded art for the cover.jpg sidecar: it's # always present once the files are arted (we may have just embedded it), # needs no API call, and the sidecar matches the files exactly # (#813/Sokhi: files have art, just no cover.jpg). Fall back to a fresh # download only when there's nothing embedded to extract. cover_path = os.path.join(target_dir, "cover.jpg") art_bytes = None for fp in paths: art_bytes = extract_embedded_art(fp) if art_bytes: break if art_bytes: try: with open(cover_path, "wb") as handle: handle.write(art_bytes) result["cover_written"] = True except OSError as exc: if getattr(exc, "errno", None) == errno.EROFS: result["read_only_fs"] = True logger.warning("cover.jpg sidecar write failed for %s: %s", target_dir, exc) if not result["cover_written"] and not result["read_only_fs"]: # No embedded art to extract → fetch it. download_cover_art swallows # its own write errors, so it records read-only on the context dict # (EROFS detection gap, Sokhi). force=True bypasses the import-time # "Download cover.jpg" toggle — running the filler is an explicit ask. cover_ctx = context if isinstance(context, dict) else {} try: download_cover_art(album_info, target_dir, cover_ctx, force=True) result["cover_written"] = folder_has_cover_sidecar(target_dir) except Exception as exc: if getattr(exc, "errno", None) == errno.EROFS: result["read_only_fs"] = True logger.warning("cover.jpg write failed for %s: %s", target_dir, exc) if cover_ctx.get("_cover_read_only"): result["read_only_fs"] = True return result