Skip to content

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

Draft
m4bard wants to merge 9 commits into
Listenarrs:canaryfrom
m4bard:feat/embed-cover-art
Draft

m4bard wants to merge 9 commits into
Listenarrs:canaryfrom
m4bard:feat/embed-cover-art

Conversation

@m4bard

@m4bard m4bard commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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.

@m4bard

m4bard commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

I've added two commits to this PR: 9611be19d, a test, and 519a256cd, a guard that skips the tag write when the import action is HardlinkCopy.

The reason is #843. With HardlinkCopy the imported file is the same inode as the download, which a client may still be seeding. On this PR alone the cover-art write never succeeds, because the pinned write stream is write-only (PinnedDirectoryCreation.FileStreams.cs:40 at a630572e9), which is the bug #843 fixes. Once #843's fix is in, embedding the cover rewrites that shared inode, so the source changes along with the library copy. I measured it with a throwaway test that runs a one-second m4b and mp3 through the real FileMover.PrepareActionForRegistrationAsync and MetadataService.WriteImportTagsAsync, comparing the source's sha256 before and after:

code action cover embedded source changed
this PR (b73b8582e) Copy no no
this PR (b73b8582e) HardlinkCopy no no
this PR merged with #843 Copy (control) yes no
this PR merged with #843 HardlinkCopy yes yes

The new test, InteractiveManualImport_ImportTags_AreNotWrittenThroughAHardlinkToTheSource, covers a Copy and a HardlinkCopy case, and checks that the HardlinkCopy destination really shares the source's inode. Its HardlinkCopy case failed before the guard and passes with it. Copy and Move are still tagged. The full backend suite passes at 519a256cd: 3129 passed, 0 failed, 130 skipped.

Readarr only writes tags on import when you turn it on (WriteAudioTags defaults to No, ConfigService.cs:274 at 0b79d3000), so a setting like that is an option if you'd rather imports leave file contents alone by default.

Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.

m4bard added a commit to m4bard/Listenarr that referenced this pull request Oct 6, 2026
…ts test

The ASIN hardlink guard from below meets Listenarrs#914's widened condition (ASIN or
cover art) in ManualImportController.ProcessItem.cs, and the conflict there
was resolved by guarding the combined tag write: under HardlinkCopy neither
the ASIN nor the artwork is written, since the destination is the source's
inode. This adds the regression test for the cover-art half, and points the
ASIN guard's test at WriteImportTagsAsync, the call Listenarrs#914 replaced
WriteAsinTagAsync with. The code is the same as fix/914-hardlink-guard's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5d0ba95)
@m4bard
m4bard marked this pull request as draft October 29, 2026 03:30
@m4bard

m4bard commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

I am moving this to draft. I think it should wait for the staged-copy tagging design in #842, which you scoped there and which I still owe a sketch for, since cover art would be a second payload written to the same staged copy and I do not want to rebuild it before that exists. The cover-art code and its tests look reusable to me, so I plan to bring them back on top of that change; tell me if you would rather I close this instead.

Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.

m4bard and others added 9 commits October 29, 2026 13:24
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.
Two XML doc blocks landed above the wrong member. In IMetadataService the
new WriteImportTagsAsync declaration was inserted between the existing
summary/param/returns for DownloadCoverArtAsync and that method, so
WriteImportTagsAsync ended up carrying two summary tags, a returns tag
describing a byte array it does not return, and a param tag naming one of
its three parameters, while DownloadCoverArtAsync ended up with no
documentation at all.

In ApplicationSettings the block describing EmbedCoverArtInAudioFiles sits
above EnableCoverArtDownload, which then carries two summary tags while the
property the text is about carries none.

Both moves are comment text only. No member, signature, initialiser or
column changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ppending

ApplyCoverArt makes a product decision in its docstring, that artwork replaces
whatever the file already carries, and nothing asserted it. Break it four ways
and the suite stays green: return false without setting Pictures, append
instead of replacing, leave MimeType unset, or use the wrong PictureType.

ApplyCoverArt becomes internal and is driven by a TagLib.File that owns no
bytes, over an ID3v2 tag seeded with an existing back cover. The assertions
are the single resulting picture, the MIME type, the picture type and the
bytes.

Checked that it discriminates: appending instead of replacing and dropping the
MimeType assignment both fail it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tag write was gated on the ASIN alone. That gate predates this feature and
was right when the ASIN was the only payload, but a book matched outside
Audible has no ASIN and does have an ImageUrl, so the new setting was silently
inert for every one of them.

The gate now admits either. WriteImportTagsAsync already handled a null asin
downstream, so the only other change is widening the parameter from string to
string? through IMetadataService, MetadataService and the test mock, which is
what the call site now actually passes.

Two controller tests, both on MetadataServiceMock.ImportTagWrites: a book with
artwork and no ASIN records one write with a null asin and the image url, and
a book with neither records none. Checked that they discriminate: narrowing
the gate back to the ASIN fails the first, opening it unconditionally fails
the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR 918 adds a migration too, and the two branches were rewriting the same two
lines of this file: the const beneath WeakStorageVerifiedCleanupMigrationId,
and the last entry of the expected post-canary list. A pairwise merge check
found that pair conflicting in both orders.

This branch's migration id now sits in its own declaration at the top of the
class and is asserted on its own, and the shared list literal goes back to the
text canary has. What is still pinned is that the branch's migration is
applied, that it sorts after the consolidated history, and that the rest of
the post-canary history is exactly the list canary has.

Checked that it discriminates: point the id at a migration that does not exist
and the test fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… file

A pairwise merge check found this branch conflicting with PR 843 in both
orders, in TagLibAudioTagWriter.cs and in its test file. Neither change is
incompatible with the other: they simply landed on adjacent lines. 843 makes
ApplyAsinTag internal so its own test can drive it, and this branch inserts
ApplyCoverArt immediately above that declaration and appends to the end of the
same test class.

So ApplyCoverArt moves below ApplyAsinTag, and this branch's test and its stub
file move above the existing tests and take a name of their own. No behaviour
changes and no assertion changes. Both orders merge clean afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regression test for the cover-art and ASIN write under HardlinkCopy. A
hardlinked destination is the source's inode, so a tag write rewrites the
file a download client may still be seeding. Copy is the control. Fails on
this commit by design; the guard follows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under HardlinkCopy the registration lease is the hardlinked destination,
the same inode as the source a download client may still be seeding, so
embedding cover art or the ASIN there rewrites the source too. Measured
with Listenarrs#843's read-write stream applied: Copy left the source unchanged,
HardlinkCopy changed it, for both m4b and mp3. The write is now skipped for
HardlinkCopy and logged at Debug; Copy and Move are tagged as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ites

The markerless mover now hands out leases that defer metadata writes, so the cover art and hardlink tests never reached the tagging call and could not tell a skipped write from a refused one. A lease that reports metadata writes as supported restores that, and the no-tag controls become real controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@m4bard
m4bard force-pushed the feat/embed-cover-art branch from 519a256 to 61d7a3e Compare October 29, 2026 19:33
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