Stop looking up a recording that has been thrown away - #39
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Nothing joined that lookup —
FinaliseAsyncreturns on the too-short and silent branches beforeAwaitEnrichmentAsync, 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.
RecordingSessioncreates a cancellation source per recording, linked to its own token, and hands it to theTrackRecorderwith 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
StopCurrentRecorderwould strip the tags off everything.Travelling with it:
%TEMP%.CoverArtFetcherdeletes 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.CancelEnrichmentswallowsObjectDisposedException: a session torn down while a recording is still finalising disposes the recorder from one thread whileFinaliseAsyncdecides 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_DeletesCoverArtTheLookupAlreadyFetchedRecord_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
IsPlayingalready true when playback starts from stopped, so the song that actually started loses its first few hundred milliseconds to the discarded recorder'sPrime(). 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