Skip to content

feat(import): embed cover art into imported files, behind a setting - #914

Open
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:feat/embed-cover-art
Open

feat(import): embed cover art into imported files, behind a setting#914
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:feat/embed-cover-art

Conversation

@m4bard

@m4bard m4bard commented Aug 31, 2026

Copy link
Copy Markdown

Closes #833, if you want the setting kept. I offered this as a branch on that issue rather than a PR because the default is a judgement call. Opening it now so it is reviewable, with that question still genuinely open.

What #833 found

EnableCoverArtDownload has been persisted since the Settings page was added and read by nothing. One declaration, no reader anywhere in the backend, and a checkbox saying "Download and embed cover art for audiobooks" that describes neither half of what happens.

The direction, and why it is not the obvious one

Checking the family first changed my answer. Readarr, Lidarr and Sonarr have no toggle at all for downloading cover images for the UI: MediaCoverService takes IConfigFileProvider only for UrlBase. The boolean of this exact shape is Lidarr's EmbedCoverArt (ConfigService.cs:308, read at AudioTagService.cs:117), labelled "Embed Cover Art In Audio Files". So in that family a flag like this governs embedding, not fetching.

That also rules out the change I would otherwise have made. Gating the existing image cache looked cheapest, but ImageCacheService cannot tell an audiobook cover from an author or series image, so a checkbox reading "cover art for audiobooks" would have silently blanked author and series artwork.

Off by default, deliberately not matching Lidarr

Lidarr defaults EmbedCoverArt to true, but hides it behind WriteAudioTags, which defaults to No, so nothing embeds out of the box there. Listenarr has no equivalent master switch and its ASIN write is unconditional, so porting the default without the gate would be more aggressive than the setting it copies.

This is the part I am least sure you will agree with. It is a one-line change if you would rather it defaulted on.

Why a new column rather than wiring the old one

EnableCoverArtDownload is true on every existing instance because that was the property initialiser, not because an operator chose it. Two approaches did not survive:

  • a data migration flipping the stored value, which MigrationProvenanceArchitectureTests forbids outright
  • renaming the column, which EF scaffolds as RenameColumn and therefore preserves the stored true, so every existing install would silently start rewriting audio files on upgrade

EmbedCoverArtInAudioFiles is added with defaultValue: false, so existing rows are off by construction. The dead flag is left in place so this stays a single additive scaffold. Dropping it is a separate mechanical change and I did not want it riding along with behaviour.

Scope

Manual import only, which is where the one existing tag write lives. Artwork and the ASIN go in one open and save, since a second pass would rewrite a file that is often several gigabytes twice. The automatic import path writes no tags at all today and is untouched, and nothing rewrites artwork for files already in the library.

The bytes are identified from their leading bytes rather than trusted from a URL or a Content-Type, because an image host answering with an HTML error page would otherwise be embedded under image/jpeg and produce a file whose cover silently fails to render.

It also gives DownloadCoverArtAsync its first production caller. It has been dead since it was written.

Conflicts to expect

This edits the pinned migration list in SqliteMigrationSchemaTests and the model snapshot, so it collides with any other PR adding a migration, including your #841 against our #763. It also collides with the release blocklist branch for the same reason. Whichever lands second wants a rebase; the resolution is keeping both ids in migration-timestamp order.

Tests

Backend 3124 passed, frontend 607 passed. The gate has a control: the test fails with the settings check removed, which I checked rather than assumed.

Happy to change the default, split the frontend copy fix out on its own, or close this if you would rather the setting were removed instead.

Worked through with Claude Code at my direction. The suite numbers are runs on this machine against this branch, and the family citations are source-reading against Lidarr and Readarr. I reviewed this before posting.

EnableCoverArtDownload has been persisted since the Settings page was
added and read by nothing. Listenarrs#833 asked which direction to take it. This
takes the reading the *arr family supports: the flag governs embedding
artwork into audio files, not whether artwork is fetched for the UI.

That distinction comes from checking the lineage first. Readarr, Lidarr
and Sonarr have no toggle at all for downloading cover images for the UI;
MediaCoverService takes IConfigFileProvider only for UrlBase. The boolean
of this exact shape in that family is Lidarr's EmbedCoverArt
(ConfigService.cs:308, read at AudioTagService.cs:117), whose label is
"Embed Cover Art In Audio Files". Gating Listenarr's image cache instead
would also have blanked author and series artwork, because the cache
chokepoint cannot tell which kind of image it is holding.

A new column rather than the old one. EnableCoverArtDownload is true on
every existing instance because that was the property initialiser, not
because an operator chose it. Reusing it would mean upgrading straight
into rewriting audio files with no opt-in step. EmbedCoverArtInAudioFiles
is added with defaultValue false, so existing installs are off by
construction and no data repair migration is needed, which the
architecture tests forbid in a PR anyway. The dead flag is left in place
so this stays a single additive EF scaffold; dropping it is a separate
mechanical change and I did not want it riding along with behaviour.

On the default, deliberately not matching Lidarr's literal true: Lidarr
hides EmbedCoverArt behind WriteAudioTags, which defaults to No, so
nothing embeds out of the box there. Listenarr has no equivalent master
switch and its ASIN write is unconditional, so porting the default
without the gate would be more aggressive than the setting it copies.

Artwork and the ASIN are written in one open and save. An ASIN is a few
bytes and a cover is commonly a few hundred kilobytes, going into an M4B
through a generation-bound lease, so a second pass would rewrite a file
that is often several gigabytes twice.

The bytes are identified by their leading bytes rather than trusted from
a URL or a Content-Type. An image host answering a request with an HTML
error page would otherwise be embedded under image/jpeg, producing a file
whose cover silently fails to render. Anything unrecognised is dropped
and the ASIN write still happens.

This also gives DownloadCoverArtAsync its first production caller; it has
been dead since it was written. It fetches from the URL rather than the
image cache: resolving a cached path means reproducing the temp-versus-
library layout handling in ImagesController, which is worth doing but not
in the same change as new behaviour.

Scope: manual import only, which is where the one existing tag write
lives. The automatic import path writes no tags at all today and is left
alone. Nothing rewrites artwork for files already in the library.

Tests: the setting gates both the write and the fetch, so a disabled
instance makes no request; an unrecognised response still writes the
ASIN; the byte sniffing names jpeg, png and webp and refuses an HTML
body. Confirmed the gate test fails with the settings check removed.
Backend 3124 passed, 0 failed, 130 skipped. Frontend 607 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EnableCoverArtDownload has no reader anywhere in the backend, and the cover-embedding machinery it advertises does not exist

1 participant