Skip to content

Stop looking up a recording that has been thrown away - #39

Merged
revtex merged 1 commit into
mainfrom
fix/abandon-enrichment-on-discard
Aug 28, 2026
Merged

Stop looking up a recording that has been thrown away#39
revtex merged 1 commit into
mainfrom
fix/abandon-enrichment-on-discard

Conversation

@revtex

@revtex revtex commented Aug 28, 2026

Copy link
Copy Markdown
Owner

What this fixes

Reported from a real session: a track was recorded for 285 ms, discarded as too short, and four seconds later the log said "Spotify" had no metadata for Icona Pop - I Love It (feat. Charli XCX) — which reads as the track matching having failed. It had not. The guard refused to tag that recording with Thank Me, which is exactly its job. What was wrong is that the lookup was still running at all.

19:09:46.731 [INF] Recording Icona Pop - I Love It (feat. Charli XCX).
19:09:47.016 [INF] Recording Able Heart - Thank Me.
19:09:47.024 [INF] Discarded Icona Pop …: shorter than 60s.
19:09:47.295 [DBG] Spotify reported nothing at all (204 No Content) while Icona Pop … was detected; asking again.
19:09:48.590 [DBG] Spotify reported Thank Me while Icona Pop … was detected; asking again.
19:09:50.862 [INF] "Spotify" had no metadata for Icona Pop - I Love It (feat. Charli XCX).

Nothing joined that lookup — FinaliseAsync returns on the too-short and silent branches before AwaitEnrichmentAsync, deliberately, so a fragment never waits on a provider — and nothing stopped it either. It spent several seconds asking about a song that had already ended, against a rate limit shared with the recording that replaced it, and reported a missing tag on a file that was never written. Worse where the answer carries no track at all: such a lookup can run the thirty-attempt no-track budget out and stand the session's advertisement handling down, on the evidence of a track that had already finished.

What changed

The lookup belongs to the recording rather than to the session. RecordingSession creates a cancellation source per recording, linked to its own token, and hands it to the TrackRecorder with the task; the recorder cancels it on the branches where it decides no file will exist.

It cannot hang off the track boundary instead: every normal recording is finalised after its song has ended — that is when the tags are joined — so cancelling in StopCurrentRecorder would strip the tags off everything.

Travelling with it:

  • Cover art the lookup had already fetched is deleted when the recording is discarded. Only the already-recorded branch ever cleaned one up, so every short recording that had enriched in time leaked an image into %TEMP%.
  • CoverArtFetcher deletes a half-written image when the fetch is cancelled mid-write — the path is lost with the exception. Cancellation only became reachable there with this change.
  • CancelEnrichment swallows ObjectDisposedException: a session torn down while a recording is still finalising disposes the recorder from one thread while FinaliseAsync decides on the other, and cancelling an already-disposed source would surface as "Recording X failed" on a recording that was merely being tidied away.

Tests

911/911 green, build clean with analyzers as errors. Four new cases:

  • Record_Discarded_StopsTheMetadataLookup — both discard outcomes, too short and silent.
  • Record_ShorterThanTheMinimum_DeletesCoverArtTheLookupAlreadyFetched
  • Record_Captured_KeepsTheCoverArtAndTheLookup — the other half of the rule: a kept recording still gets its art, and its lookup is not cancelled.
  • Session_DiscardingAShortRecording_CancelsOnlyThatTracksLookup — asserts the next track's lookup is still live, which is what makes this a per-recording cancellation rather than the session's.

Not fixed here

The reason the fragment existed: the media session reports the previous track's properties with IsPlaying already true when playback starts from stopped, so the song that actually started loses its first few hundred milliseconds to the discarded recorder's Prime(). Fixing that changes what audio a recording contains — a settle window alone would make the clipping worse, and keeping the buffer trades it for an advertisement's tail bleeding into the next track — so it wants deciding on its own.

🤖 Generated with Claude Code

Resume Spotify on a song a fraction of a second before it ends - or start a new
one from a stopped player, where the media session reports the previous track
for a few hundred milliseconds - and Offstream starts a recording it discards a
moment later for being under the minimum length. The metadata lookup that
recording started carried on regardless.

Nothing joined it: FinaliseAsync returns on the too-short and silent branches
before AwaitEnrichmentAsync, deliberately, so a fragment never waits on a
provider. Nothing stopped it either. So it spent the next several seconds
asking Spotify about a song that had already finished, against a rate limit
shared with the recording that replaced it, and then announced "had no metadata"
for a file that was never written - which reads as the track matching having
failed when it was working exactly as intended. Worse in the case where the
answer carries no track at all: that lookup can run the thirty-attempt no-track
budget out and stand the session's advertisement handling down, on the evidence
of a track that had already ended.

The lookup now belongs to the recording rather than to the session. The session
creates a cancellation source per recording, linked to its own token, and hands
it to the TrackRecorder with the task; the recorder cancels it on the branches
where it decides no file will exist.

It cannot hang off the track boundary instead. Every normal recording is
finalised after its song has ended - that is when the tags are joined - so
cancelling in StopCurrentRecorder would strip the tags off everything.

Two smaller things travel with it:

Cover art the lookup had already fetched is deleted when the recording is
discarded. The lookup usually finishes before a short recording is decided, so
the image was already on disk with nothing left to reference it, and only the
already-recorded branch ever cleaned one up. CoverArtFetcher also deletes a
half-written image when the fetch is cancelled mid-write, because the path it
would be found by is lost with the exception - cancellation only became
reachable there with this change.

CancelEnrichment swallows ObjectDisposedException. A session torn down while a
recording is still finalising disposes the recorder from one thread while
FinaliseAsync decides on the other, and cancelling an already-disposed source
would surface as "Recording X failed" on a recording that was merely being
tidied away.

Not fixed here, and the reason the fragment existed at all: the media session
reports the previous track's properties with IsPlaying already true, so the
song that actually started loses its first few hundred milliseconds to the
discarded recorder's Prime(). That is a change to what audio a recording
contains and wants deciding on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@revtex
revtex merged commit 61e0a38 into main Aug 28, 2026
6 checks passed
@revtex
revtex deleted the fix/abandon-enrichment-on-discard branch August 28, 2026 23:43
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.

1 participant