maxrave-dev / maxrave-dev/SimpMusic
Downloaded songs stop mid-track with a network error (partial download recorded as complete)
Nobody has claimed this yet.
- Dominant language
- Kotlin
- Stars
- 11.4k
- Forks
- 599
- Avg merge
- 3d 18h
- Merged PRs (30d)
- 7
Description
Problem Description
Songs marked as downloaded stop playing part-way through and surface a network error. The cut-off is consistent for a given track (for me, around the 8:25 mark), which points at a fixed byte offset rather than anything time-based. Playback should not touch the network at all for a downloaded track.
Analysis
I think there are two halves to this, both in core (maxrave-dev/core), and both stemming from the same one-byte cache probe.
- Downloads can be truncated — media/media3/.../service/download/DownloadUtils.kt
The ResolvingDataSource in DownloadUtils short-circuits URL resolution like this:
kotlin
val length = if (dataSpec.length >= 0) dataSpec.length else 1
if (downloadCache.isCached(mediaId, dataSpec.position, length) || playerCache.isCached(mediaId, dataSpec.position, length)) {
return@Factory dataSpec
}
When dataSpec.length is LENGTH_UNSET (the normal case for a whole-file download) this probes a single byte. If the user streamed the track before downloading it, the player cache holds the first chunk, so the probe hits and the resolver returns the DataSpec unchanged — meaning its URI is still the bare video ID from DownloadRequest.Builder(videoId, videoId.toUri()).
ResolvingDataSource wraps the outside of the chain, so the URI is resolved once per open(). CacheDataSource then serves the cached span and, for the remainder, opens the upstream OkHttpDataSource on dQw4w9WgXcQ — not a resolvable URL. The download stops there.
The size of that span is the giveaway: the player resolver caps every network read at chunkLength = 10 * 512 * 1024 (5 MiB). 5 MiB of ~85 kbps Opus is ≈ 8m20s, which matches the observed cut-off closely.
- Partial downloads are then treated as complete on playback — media/media3/.../di/Media3ServiceModule.kt
provideResolvingDataSourceFactory uses the same one-byte probe:
kotlin
val length = if (dataSpec.length >= 0) dataSpec.length else 1
if (downloadCache.isCached(mediaId, dataSpec.position, length)) {
...
return@Factory dataSpec // URI is still the bare video ID
}
This is exactly the failure mode the existing comment a few lines below already warns about for the player cache branch:
// Don't return bare video ID as URI — CacheDataSource.openNextSource() // may need a valid HTTP URL for uncached spans beyond this chunk.
The download-cache branch above it was never given the same treatment. So a partial download plays fine off cache and then throws on the first uncached byte — reproducibly at the same offset every time, which is what makes it look like a timer rather than a truncation.
Proposed Fix
Replace the one-byte probe with a check that the cache can actually serve the whole remaining range, using the content length recorded in the cache index:
kotlin
internal fun Cache.canServeFully(key: String, dataSpec: DataSpec): Boolean {
val contentLength = ContentMetadata.getContentLength(getContentMetadata(key))
val needed = when {
dataSpec.length != C.LENGTH_UNSET.toLong() -> dataSpec.length
contentLength != C.LENGTH_UNSET.toLong() -> contentLength - dataSpec.position
else -> return false
}
if (needed <= 0L) return true
return getCachedLength(key, dataSpec.position, needed) >= needed
}
and use it in both resolvers, dropping the playerCache short-circuit in DownloadUtils entirely (the player cache holds at most a 5 MiB chunk, so it can never legitimately satisfy a download).
Effects:
A genuinely complete download still short-circuits, so true offline playback is unaffected.
A partial download falls through to normal URL resolution instead of dying, so an interrupted download self-heals on the next play.
Downloads always start from a real stream URL, so they run to completion.
Patch attached / in the linked branch. Note this touches the core submodule, not the app repo.
- Detecting and repairing already-truncated downloads
The resolver fix stops new truncation but does not repair existing entries, and the cache cannot self-diagnose: a truncated download recorded whatever length its truncated upstream reported, so the cache metadata agrees with itself. Confirmed by a user report — the track only plays correctly after un-downloading and re-downloading it.
The trustworthy length is already in the database: NewFormatEntity.contentLength, the stream size YouTube reported, persisted per videoId and readable offline. So:
kotlin
private suspend fun isDownloadTruncated(videoId: String): Boolean? {
val expected = streamRepository.getNewFormat(videoId).lastOrNull()?.contentLength ?: return null
if (expected <= 0L) return null
return downloadCache.getCachedBytes(videoId, 0, expected) < expected
}
revalidateDownloads() walks downloadIndex.getDownloads(STATE_COMPLETED), drops anything short via sendRemoveDownload + removeResource, sets STATE_NOT_DOWNLOADED, and re-queues. It returns null rather than false when no expected length is known, so unverifiable downloads are never deleted. Metadata only — no stream reads — so running it once at startup is cheap.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in media/media3/.../service/download/DownloadUtils.kt and media/media3/.../di/Media3ServiceModule.kt, then trace the cache resolver and revalidateDownloads entry point. Check how NewFormatEntity.contentLength and downloadIndex completed entries are used. Done means complete downloads still resolve offline, partial downloads resume through a real stream URL, and existing truncated entries are safely re-queued.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, kotlin
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100