Cover Art Filler: write cover.jpg sidecars, even when files already have embedded art (Sokhi #813)
Sokhi: after the earlier flag fix, scans returned 0 matches — albums with embedded art but no cover.jpg were treated as fully arted and skipped, so their cover.jpg never got written. Root cause: the scan used album_has_art_on_disk (True if EITHER embedded art OR a cover.jpg exists), conflating the two. Now it checks them separately: - a local album is flagged if it lacks embedded art, OR it lacks a cover.jpg sidecar AND cover.jpg writing is enabled (metadata_enhancement.cover_art_ download — Boulder: "only scan for cover.jpgs when enabled"). - an album that has embedded art but no sidecar is fixable even when the API finds no art: the apply writes cover.jpg from the EXISTING embedded art. apply_art_to_album_files now writes the cover.jpg sidecar by extracting the album's own embedded art (new extract_embedded_art) — consistent with the files, no API call — and only falls back to download_cover_art when there's nothing embedded to extract. _fix_missing_cover_art no longer bails on a missing artwork_url when sidecar_from_embedded is set. Tests: scan flags embedded-but-no-cover.jpg (incl. when API finds nothing), still skips albums with both, still flags artless albums; apply writes cover.jpg from embedded art (no download), falls back to download when none, skips when a sidecar already exists; extract_embedded_art unit tests. 1344 cover/art/repair tests pass.
This commit is contained in:
parent
c654deac17
commit
5352db0a22
5 changed files with 210 additions and 46 deletions
|
|
@ -17,7 +17,7 @@ from __future__ import annotations
|
||||||
import contextlib
|
import contextlib
|
||||||
import errno
|
import errno
|
||||||
import os
|
import os
|
||||||
from typing import Iterable
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
from core.metadata.artwork import download_cover_art, embed_album_art_metadata
|
from core.metadata.artwork import download_cover_art, embed_album_art_metadata
|
||||||
from core.metadata.common import get_mutagen_symbols
|
from core.metadata.common import get_mutagen_symbols
|
||||||
|
|
@ -81,6 +81,38 @@ def _audio_has_art(audio, symbols) -> bool:
|
||||||
return False
|
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:
|
def album_has_art_on_disk(rep_file_path: str) -> bool:
|
||||||
"""Does this album have art on disk?
|
"""Does this album have art on disk?
|
||||||
|
|
||||||
|
|
@ -167,24 +199,41 @@ def apply_art_to_album_files(
|
||||||
result["failed"] += 1
|
result["failed"] += 1
|
||||||
|
|
||||||
target_dir = folder or (os.path.dirname(paths[0]) if paths else None)
|
target_dir = folder or (os.path.dirname(paths[0]) if paths else None)
|
||||||
if target_dir and os.path.isdir(target_dir):
|
if target_dir and os.path.isdir(target_dir) and not folder_has_cover_sidecar(target_dir):
|
||||||
# download_cover_art swallows its own write errors, so a read-only mount
|
# Prefer the album's OWN embedded art for the cover.jpg sidecar: it's
|
||||||
# (EROFS) on the cover.jpg sidecar would otherwise go undetected here —
|
# always present once the files are arted (we may have just embedded it),
|
||||||
# the exact gap that left cover-only albums reporting success on a
|
# needs no API call, and the sidecar matches the files exactly
|
||||||
# read-only filesystem (Sokhi: tracks already had embedded art, so the
|
# (#813/Sokhi: files have art, just no cover.jpg). Fall back to a fresh
|
||||||
# embed loop above never tripped EROFS). Pass a capture dict and read
|
# download only when there's nothing embedded to extract.
|
||||||
# the read-only flag it sets back.
|
cover_path = os.path.join(target_dir, "cover.jpg")
|
||||||
cover_ctx = context if isinstance(context, dict) else {}
|
art_bytes = None
|
||||||
try:
|
for fp in paths:
|
||||||
# force=True: the Cover Art Filler always writes the cover.jpg sidecar,
|
art_bytes = extract_embedded_art(fp)
|
||||||
# regardless of the import-time "Download cover.jpg" toggle — running
|
if art_bytes:
|
||||||
# the art filler is an explicit request for the complete art (Sokhi).
|
break
|
||||||
download_cover_art(album_info, target_dir, cover_ctx, force=True)
|
if art_bytes:
|
||||||
result["cover_written"] = folder_has_cover_sidecar(target_dir)
|
try:
|
||||||
except Exception as exc:
|
with open(cover_path, "wb") as handle:
|
||||||
if getattr(exc, "errno", None) == errno.EROFS:
|
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
|
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
|
return result
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from core.metadata.art_apply import album_has_art_on_disk
|
from core.metadata.art_apply import file_has_embedded_art, folder_has_cover_sidecar
|
||||||
from core.library.path_resolver import resolve_library_file_path
|
from core.library.path_resolver import resolve_library_file_path
|
||||||
from core.metadata_service import get_client_for_source, get_primary_source, get_source_priority
|
from core.metadata_service import get_client_for_source, get_primary_source, get_source_priority
|
||||||
from core.repair_jobs import register_job
|
from core.repair_jobs import register_job
|
||||||
|
|
@ -177,18 +177,33 @@ class MissingCoverArtJob(RepairJob):
|
||||||
config_manager=context.config_manager,
|
config_manager=context.config_manager,
|
||||||
) if rep_path else None
|
) if rep_path else None
|
||||||
has_local = bool(resolved_rep)
|
has_local = bool(resolved_rep)
|
||||||
disk_missing = has_local and not album_has_art_on_disk(resolved_rep)
|
# Check embedded art and the cover.jpg sidecar SEPARATELY (not the
|
||||||
# An album whose FILES already have art is not "missing cover art",
|
# combined album_has_art_on_disk, which returns True if EITHER is
|
||||||
# even if the DB thumb_url cache happens to be empty — the library
|
# present). An album can have embedded art but no cover.jpg — and if
|
||||||
# shows the file art either way. So for a local album, flag only when
|
# the user wants cover.jpg files, that's still a fixable "missing"
|
||||||
# the files actually lack art. db_missing alone matters only for
|
# (Sokhi: scans returned 0 because embedded-art albums were treated
|
||||||
# media-server-only albums (no local files), where the DB thumb is
|
# as fully arted and skipped, so their cover.jpg never got written).
|
||||||
# the sole art. Without this, every local album with an empty
|
has_embedded = file_has_embedded_art(resolved_rep) if has_local else False
|
||||||
# thumb_url got flagged despite having perfectly good embedded art
|
has_sidecar = folder_has_cover_sidecar(os.path.dirname(resolved_rep)) if has_local else False
|
||||||
# (Boulder: "flags every album, but everything has art").
|
# cover.jpg sidecars are only a "missing" thing when the user has
|
||||||
|
# cover.jpg writing enabled (Boulder: "only scan for cover.jpgs when
|
||||||
|
# they're enabled"). Default on.
|
||||||
|
cover_sidecar_enabled = bool(
|
||||||
|
context.config_manager.get('metadata_enhancement.cover_art_download', True)
|
||||||
|
if context.config_manager else True)
|
||||||
|
|
||||||
if has_local:
|
if has_local:
|
||||||
needs_fix = disk_missing
|
embed_missing = not has_embedded
|
||||||
|
sidecar_missing = cover_sidecar_enabled and not has_sidecar
|
||||||
|
# has_embedded + no sidecar → the apply writes cover.jpg from the
|
||||||
|
# existing embedded art, so it's fixable even if the API finds no
|
||||||
|
# art. embed_missing still requires API art (nothing to embed).
|
||||||
|
sidecar_from_embedded = sidecar_missing and has_embedded
|
||||||
|
needs_fix = embed_missing or sidecar_missing
|
||||||
else:
|
else:
|
||||||
|
# Media-server-only album: the DB thumb is the only art.
|
||||||
|
embed_missing = db_missing
|
||||||
|
sidecar_from_embedded = False
|
||||||
needs_fix = db_missing
|
needs_fix = db_missing
|
||||||
if not needs_fix:
|
if not needs_fix:
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
|
|
@ -226,10 +241,14 @@ class MissingCoverArtJob(RepairJob):
|
||||||
if artwork_url:
|
if artwork_url:
|
||||||
break
|
break
|
||||||
|
|
||||||
if artwork_url:
|
# Fixable if we found API art (to embed/write), OR it's just a
|
||||||
|
# missing cover.jpg on an album that already has embedded art — the
|
||||||
|
# apply writes the sidecar from that embedded art, no API art needed.
|
||||||
|
if artwork_url or sidecar_from_embedded:
|
||||||
if context.report_progress:
|
if context.report_progress:
|
||||||
context.report_progress(
|
context.report_progress(
|
||||||
log_line=f'Found art: {title or "Unknown"}',
|
log_line=(f'Found art: {title or "Unknown"}' if artwork_url
|
||||||
|
else f'Will write cover.jpg from embedded art: {title or "Unknown"}'),
|
||||||
log_type='success'
|
log_type='success'
|
||||||
)
|
)
|
||||||
# Also search for an artist image so the finding can offer it as
|
# Also search for an artist image so the finding can offer it as
|
||||||
|
|
@ -241,6 +260,9 @@ class MissingCoverArtJob(RepairJob):
|
||||||
# Create finding for user to approve
|
# Create finding for user to approve
|
||||||
if context.create_finding:
|
if context.create_finding:
|
||||||
try:
|
try:
|
||||||
|
_desc = (f'Album "{title}" by {artist_name or "Unknown"} has no cover art. '
|
||||||
|
+ ('Found artwork from API.' if artwork_url
|
||||||
|
else 'Will write cover.jpg from the existing embedded art.'))
|
||||||
inserted = context.create_finding(
|
inserted = context.create_finding(
|
||||||
job_id=self.job_id,
|
job_id=self.job_id,
|
||||||
finding_type='missing_cover_art',
|
finding_type='missing_cover_art',
|
||||||
|
|
@ -249,7 +271,7 @@ class MissingCoverArtJob(RepairJob):
|
||||||
entity_id=str(album_id),
|
entity_id=str(album_id),
|
||||||
file_path=None,
|
file_path=None,
|
||||||
title=f'Missing artwork: {title or "Unknown"}',
|
title=f'Missing artwork: {title or "Unknown"}',
|
||||||
description=f'Album "{title}" by {artist_name or "Unknown"} has no cover art. Found artwork from API.',
|
description=_desc,
|
||||||
details={
|
details={
|
||||||
'album_id': album_id,
|
'album_id': album_id,
|
||||||
'album_title': title,
|
'album_title': title,
|
||||||
|
|
@ -268,7 +290,8 @@ class MissingCoverArtJob(RepairJob):
|
||||||
# apply can embed into the audio + write cover.jpg.
|
# apply can embed into the audio + write cover.jpg.
|
||||||
'album_folder': os.path.dirname(rep_path) if rep_path else None,
|
'album_folder': os.path.dirname(rep_path) if rep_path else None,
|
||||||
'db_missing': db_missing,
|
'db_missing': db_missing,
|
||||||
'disk_missing': disk_missing,
|
'embed_missing': embed_missing,
|
||||||
|
'sidecar_from_embedded': sidecar_from_embedded,
|
||||||
'musicbrainz_release_id': None,
|
'musicbrainz_release_id': None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1325,7 +1325,11 @@ class RepairWorker:
|
||||||
artist_result = self._fix_artist_art(album_id, details)
|
artist_result = self._fix_artist_art(album_id, details)
|
||||||
|
|
||||||
artwork_url = details.get('found_artwork_url')
|
artwork_url = details.get('found_artwork_url')
|
||||||
if not artwork_url:
|
# sidecar_from_embedded: the album already has embedded art and just needs
|
||||||
|
# a cover.jpg sidecar — the apply writes it from the existing embedded art,
|
||||||
|
# so no API artwork_url is required (Sokhi #813).
|
||||||
|
sidecar_from_embedded = bool(details.get('sidecar_from_embedded'))
|
||||||
|
if not artwork_url and not sidecar_from_embedded:
|
||||||
# 'both' but no album art — report the artist outcome if that ran.
|
# 'both' but no album art — report the artist outcome if that ran.
|
||||||
if artist_result is not None:
|
if artist_result is not None:
|
||||||
return artist_result
|
return artist_result
|
||||||
|
|
|
||||||
|
|
@ -247,3 +247,70 @@ def test_filler_forces_cover_sidecar_write(tmp_path, monkeypatch):
|
||||||
aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
|
aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
|
||||||
|
|
||||||
assert captured.get('force') is True
|
assert captured.get('force') is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── cover.jpg from embedded art (#813/Sokhi: files have art, no sidecar) ──
|
||||||
|
|
||||||
|
def test_extract_embedded_art_flac(tmp_path, monkeypatch):
|
||||||
|
f = tmp_path / 'a.flac'; f.write_bytes(b'')
|
||||||
|
audio = SimpleNamespace(pictures=[SimpleNamespace(data=b'IMGBYTES')], tags=None)
|
||||||
|
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
|
||||||
|
assert aa.extract_embedded_art(str(f)) == b'IMGBYTES'
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_embedded_art_none_when_no_pictures(tmp_path, monkeypatch):
|
||||||
|
f = tmp_path / 'a.flac'; f.write_bytes(b'')
|
||||||
|
audio = SimpleNamespace(pictures=[], tags=None)
|
||||||
|
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
|
||||||
|
assert aa.extract_embedded_art(str(f)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_cover_jpg_from_embedded_art(tmp_path, monkeypatch):
|
||||||
|
# Files already have embedded art, no cover.jpg → apply extracts it and
|
||||||
|
# writes the sidecar WITHOUT an API fetch (consistent + offline).
|
||||||
|
f = tmp_path / '01.flac'; f.write_bytes(b'')
|
||||||
|
audio = SimpleNamespace(pictures=[SimpleNamespace(data=b'EMBEDDED')], tags=None, save=lambda: None)
|
||||||
|
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
|
||||||
|
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
|
||||||
|
dl_called = []
|
||||||
|
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: dl_called.append(1))
|
||||||
|
|
||||||
|
res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
|
||||||
|
|
||||||
|
assert (tmp_path / 'cover.jpg').read_bytes() == b'EMBEDDED'
|
||||||
|
assert res['cover_written'] is True
|
||||||
|
assert dl_called == [] # used embedded art — no API call
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_falls_back_to_download_when_no_embedded(tmp_path, monkeypatch):
|
||||||
|
# No embedded art to extract → fetch the sidecar via download_cover_art.
|
||||||
|
f = tmp_path / '01.flac'; f.write_bytes(b'')
|
||||||
|
audio = SimpleNamespace(pictures=[], tags=None, add_tags=lambda: None, save=lambda: None)
|
||||||
|
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
|
||||||
|
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
|
||||||
|
dl_called = []
|
||||||
|
|
||||||
|
def fake_dl(album_info, target, ctx=None, **k):
|
||||||
|
dl_called.append(1)
|
||||||
|
open(f"{target}/cover.jpg", 'wb').close()
|
||||||
|
monkeypatch.setattr(aa, 'download_cover_art', fake_dl)
|
||||||
|
|
||||||
|
res = aa.apply_art_to_album_files([str(f)], {'album_art_url': 'http://x/y.jpg'},
|
||||||
|
{'album_name': 'B'}, folder=str(tmp_path))
|
||||||
|
assert dl_called == [1] # no embedded art → API fetch
|
||||||
|
assert res['cover_written'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_cover_when_sidecar_exists(tmp_path, monkeypatch):
|
||||||
|
# cover.jpg already present → don't extract or download.
|
||||||
|
(tmp_path / 'cover.jpg').write_bytes(b'EXISTING')
|
||||||
|
f = tmp_path / '01.flac'; f.write_bytes(b'')
|
||||||
|
audio = SimpleNamespace(pictures=[SimpleNamespace(data=b'X')], tags=None, save=lambda: None)
|
||||||
|
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
|
||||||
|
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
|
||||||
|
dl_called = []
|
||||||
|
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: dl_called.append(1))
|
||||||
|
|
||||||
|
aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
|
||||||
|
assert (tmp_path / 'cover.jpg').read_bytes() == b'EXISTING' # untouched
|
||||||
|
assert dl_called == []
|
||||||
|
|
|
||||||
|
|
@ -303,13 +303,14 @@ def test_scan_checks_disk_art_on_resolved_path(monkeypatch):
|
||||||
checked = {}
|
checked = {}
|
||||||
monkeypatch.setattr(mca, 'resolve_library_file_path',
|
monkeypatch.setattr(mca, 'resolve_library_file_path',
|
||||||
lambda raw, **k: '/resolved/song.flac' if raw == '/plex/raw/song.flac' else None)
|
lambda raw, **k: '/resolved/song.flac' if raw == '/plex/raw/song.flac' else None)
|
||||||
monkeypatch.setattr(mca, 'album_has_art_on_disk',
|
monkeypatch.setattr(mca, 'file_has_embedded_art',
|
||||||
lambda p: checked.update(path=p) or True)
|
lambda p: checked.update(path=p) or True)
|
||||||
|
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: True) # has cover.jpg too
|
||||||
|
|
||||||
result = mca.MissingCoverArtJob().scan(context)
|
result = mca.MissingCoverArtJob().scan(context)
|
||||||
|
|
||||||
assert checked.get('path') == '/resolved/song.flac' # resolved, not raw
|
assert checked.get('path') == '/resolved/song.flac' # resolved, not raw
|
||||||
assert result.findings_created == 0 # thumb + disk art → not flagged
|
assert result.findings_created == 0 # embedded + cover.jpg → not flagged
|
||||||
|
|
||||||
|
|
||||||
def test_scan_unresolvable_path_not_flagged_disk_missing(monkeypatch):
|
def test_scan_unresolvable_path_not_flagged_disk_missing(monkeypatch):
|
||||||
|
|
@ -320,7 +321,8 @@ def test_scan_unresolvable_path_not_flagged_disk_missing(monkeypatch):
|
||||||
context = _make_context(conn)
|
context = _make_context(conn)
|
||||||
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: None)
|
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: None)
|
||||||
called = []
|
called = []
|
||||||
monkeypatch.setattr(mca, 'album_has_art_on_disk', lambda p: called.append(p) or False)
|
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: called.append(p) or False)
|
||||||
|
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: called.append(d) or False)
|
||||||
|
|
||||||
result = mca.MissingCoverArtJob().scan(context)
|
result = mca.MissingCoverArtJob().scan(context)
|
||||||
|
|
||||||
|
|
@ -328,22 +330,40 @@ def test_scan_unresolvable_path_not_flagged_disk_missing(monkeypatch):
|
||||||
assert called == [] # never checked art on a None path
|
assert called == [] # never checked art on a None path
|
||||||
|
|
||||||
|
|
||||||
def test_local_album_with_file_art_not_flagged_despite_empty_thumb(monkeypatch):
|
def test_local_album_with_embedded_and_sidecar_not_flagged(monkeypatch):
|
||||||
# Boulder: every album flagged, but "everything has art". Local files HAVE
|
# Has BOTH embedded art AND a cover.jpg — nothing missing, even with an
|
||||||
# embedded art; only the DB thumb_url cache is empty. That is NOT missing
|
# empty DB thumb cache. (Boulder: don't flag albums that already have art.)
|
||||||
# cover art — must not be flagged.
|
|
||||||
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None)) # empty thumb
|
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None)) # empty thumb
|
||||||
_add_track(conn, '/music/Album/01.flac')
|
_add_track(conn, '/music/Album/01.flac')
|
||||||
context = _make_context(conn)
|
context = _make_context(conn)
|
||||||
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw)
|
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw)
|
||||||
monkeypatch.setattr(mca, 'album_has_art_on_disk', lambda p: True) # files have art
|
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: True)
|
||||||
|
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: True)
|
||||||
|
|
||||||
result = mca.MissingCoverArtJob().scan(context)
|
result = mca.MissingCoverArtJob().scan(context)
|
||||||
|
|
||||||
assert result.findings_created == 0 # has file art → not "missing"
|
assert result.findings_created == 0 # has both → not "missing"
|
||||||
assert result.skipped == 1
|
assert result.skipped == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedded_art_but_no_cover_jpg_is_flagged(monkeypatch):
|
||||||
|
# Sokhi: files HAVE embedded art but no cover.jpg sidecar. With cover.jpg
|
||||||
|
# enabled (default), it's flagged so the filler writes the sidecar — even
|
||||||
|
# when the API finds NO art (the apply extracts the embedded art).
|
||||||
|
conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None))
|
||||||
|
_add_track(conn, '/music/Album/01.flac')
|
||||||
|
context = _make_context(conn)
|
||||||
|
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw)
|
||||||
|
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: True) # embedded present
|
||||||
|
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: False) # but no cover.jpg
|
||||||
|
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
|
||||||
|
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient()) # API finds nothing
|
||||||
|
|
||||||
|
result = mca.MissingCoverArtJob().scan(context)
|
||||||
|
assert result.findings_created == 1 # flagged for the missing sidecar
|
||||||
|
assert context.findings[0]['details']['sidecar_from_embedded'] is True
|
||||||
|
|
||||||
|
|
||||||
def test_local_album_without_file_art_still_flagged(monkeypatch):
|
def test_local_album_without_file_art_still_flagged(monkeypatch):
|
||||||
# Local album whose files genuinely lack art → still flagged (real case).
|
# Local album whose files genuinely lack art → still flagged (real case).
|
||||||
# Give it a source id + findable art so a finding is created when flagged.
|
# Give it a source id + findable art so a finding is created when flagged.
|
||||||
|
|
@ -351,7 +371,8 @@ def test_local_album_without_file_art_still_flagged(monkeypatch):
|
||||||
_add_track(conn, '/music/Album/01.flac')
|
_add_track(conn, '/music/Album/01.flac')
|
||||||
context = _make_context(conn)
|
context = _make_context(conn)
|
||||||
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw)
|
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw)
|
||||||
monkeypatch.setattr(mca, 'album_has_art_on_disk', lambda p: False) # no art on disk
|
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: False) # no embedded art
|
||||||
|
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: False)
|
||||||
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
|
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
|
||||||
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient(album_image='https://img/x'))
|
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient(album_image='https://img/x'))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue