Skip to content

A manual import refused at registration leaves the prepared file in the library folder, with nothing in the catalog pointing at it #998

Description

@m4bard

Summary

When a manual import is refused, the file has already been written to its destination inside the library folder, and the refusal path leaves it there. The catalog holds no row for it, the source file is untouched, and the API reply says only that the file could not be published and registered safely. So the library folder ends up holding a file the application cannot see, and nothing tells the user it is there.

What was measured, and the control

Measured on stock ghcr.io/listenarrs/listenarr:canary, digest sha256:216f95f37d0179fc8c2638f24c599ed09d18c808a3d727d8a22b4bf8a2bff9c4. Fixtures are one second of silence built with ffmpeg at run time, imported with POST /api/v1/library/manual-import against an audiobook added from a generated test corpus. Stream contents were read back with ffprobe rather than assumed from the filenames.

Each case was judged on three observables, since a file sitting at the destination with no database row behind it has not been imported:

  1. the API reply
  2. the filesystem at the source and at the planned destination, read from outside the container
  3. the catalog file rows, from GET /api/v1/library/{id}/files-debug
fixture verdict destination source catalog file rows
audio-only.mp4 refused file left behind preserved 0
with-cover.mp4 (audio, plus a stream with attached_pic=1) refused file left behind preserved 0
with-video.mp4 (audio, plus a playable video stream) refused file left behind preserved 0
video-only.mp4 refused file left behind preserved 0
empty.mp4 (no streams at all) refused file left behind preserved 0
audio-only.mkv refused file left behind preserved 0
same-bytes.m4b (sha256 identical to audio-only.mp4) imported consumed into the library consumed 1

That last row is the control. The one fixture that imports leaves nothing behind, and its bytes are identical to the first fixture, which does leave something behind. All six refusals left a file in the library folder and produced no catalog row, and all six came back with the same text: "The file could not be published and registered safely."

Not specific to one file type

The refused fixtures cover three different stream shapes in an .mp4, an .mp4 with no streams at all, and an .mkv. Those refusals do not share a cause, and the file left behind is the same in every one of them. The fixtures look like this because they came out of measuring #995; the leftover file is the part that has nothing to do with .mp4.

Why it matters

No row references the file, so nothing in the application will surface it or tidy it up. For these fixtures a later library scan does not find it either, since the scan walk filters on extension before it looks at anything else (ScanFileDiscovery.Enumeration.cs:76, read). I did not measure a refusal of a file whose extension the scanner does accept, so I cannot say what happens in that case.

The reply gives the user nothing to act on. It says the import did not happen, which is accurate, but it never mentions the file that got written on the way to working that out. Retry a failing import a few times and the folder holds several copies nobody put there.

What I read

Graded read, at canary a630572e9. Not executed.

  • ManualImportController.ProcessItem.cs:299-311 calls PrepareOwnedManualImportActionForRegistrationAsync, which reaches IFileMover.PrepareActionForRegistrationAsync. In the markerless path the destination is published during that preparation step (FileMover.MarkerlessRegistration.cs:178-189), and for a Move the source is deleted only later, in the completion step (FileMover.MarkerlessRegistrationMove.cs:194). That ordering matches what the matrix shows, with the destination written and the source still in place.
  • Registration runs after preparation (ProcessItem.cs:327-343). When it returns false, ProcessItem.cs:344-354 returns the failure reply, and nothing on that path removes the prepared destination. The neighbouring failure branch at :356-375 does call RollbackPublishedGenerationIfStaleAsync, but that rolls back a database generation rather than the file.
  • Leaving the using block does not remove it either. PinnedAudiobookFileRegistrationLease.Dispose() (PinnedAudiobookFileRegistrationLease.cs:423-432) disposes the stable handle and the file handle and does nothing else.

I am not claiming which refusal branch each fixture above took. Four sites return that one message, and the reply does not distinguish them.

What this does not claim

  • Nothing about where the cleanup belongs, or about what should happen to the mutation journal the prepared operation leaves behind. I have not checked what state that journal is in after a refused registration, and A single legacy file-mutation journal disables all filesystem operations permanently, and there is no in-app way to clear it #865 suggests a lingering journal is not free.
  • Nothing about a restart. All of the above was measured inside a single run, so whether startup reconciliation would remove the file is untested.
  • Nothing about automatic import. Only the manual import endpoint was exercised.
  • This is stock canary behaviour at the digest named above, not something I introduced, and it held for every refused fixture rather than intermittently.

Overlap with #993

Two of the three files cited above are in #993's diff, FileMover.MarkerlessRegistration.cs and PinnedAudiobookFileRegistrationLease.cs (checked with gh pr view 993 --json files). ManualImportController.ProcessItem.cs is not. Since the cleanup may well belong in the part you are rewriting, this is evidence rather than a patch. I am happy to contribute a regression test that fails on the current behaviour and passes once a refused import leaves the folder empty, in whatever shape fits #993.

Reproduction

  1. Stock canary at the digest above, one root folder, one audiobook in the library.
  2. Build a file the import will refuse. A container with no audio stream is the simplest: ffmpeg -f lavfi -i color=c=black:s=64x64:d=1 -t 1 -c:v libx264 video-only.mp4.
  3. Put it in a directory outside every root folder, list it with GET /api/v1/library/manual-import/preview?path=<that directory>, then POST /api/v1/library/manual-import with the item matched to the audiobook.
  4. The reply is a failure carrying "The file could not be published and registered safely."
  5. Look in the audiobook's folder under the root folder. The file is there, named by the naming pattern. GET /api/v1/library/{id}/files-debug reports no row for it.

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions