-
Notifications
You must be signed in to change notification settings - Fork 16
fix(downloads): play every track of server-prepared downloads offline #417
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Quick104
merged 12 commits into
Silo-Server:main
from
Rhainland:fix/offline-multitrack-downloads
Sep 29, 2026
+2,616
−37
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
d990468
fix(downloads): play every track of server-prepared downloads offline
Rhainland d654ba6
fix(downloads): retry a transient offline manifest failure
Rhainland a2f8851
fix(player): keep offline subtitle choices off the server
Rhainland 531d180
fix(player): never wait on the server for offline subtitle preferences
Rhainland 0b03d52
fix(player): keep offline preference sources consistent with online
Rhainland 5792664
fix(settings): update the cached profile in place after a settings write
Rhainland 0764c2d
fix(settings): mirror the shown subtitle settings into the cached pro…
Rhainland 59c0eb3
fix(settings): cache only confirmed subtitle values
Rhainland 9625639
fix(downloads): preserve offline track cleanup and preferences
Quick104 b218ad1
fix(settings): ignore subtitle responses after a newer success
Quick104 b794719
fix(downloads): recover completion reporting after publication
Quick104 c694c30
fix(player): omit offline subtitle identity from logs
Quick104 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
989 changes: 989 additions & 0 deletions
989
android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/13.json
Large diffs are not rendered by default.
Oops, something went wrong.
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
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
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
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
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
176 changes: 176 additions & 0 deletions
176
...d/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| package org.siloserver.silo.common.downloads | ||
|
|
||
| import android.util.Log | ||
| import io.ktor.client.HttpClient | ||
| import io.ktor.client.plugins.HttpTimeoutConfig | ||
| import io.ktor.client.plugins.timeout | ||
| import io.ktor.client.request.HttpRequestBuilder | ||
| import io.ktor.client.request.get | ||
| import io.ktor.client.request.prepareGet | ||
| import io.ktor.client.statement.bodyAsChannel | ||
| import io.ktor.client.statement.bodyAsText | ||
| import io.ktor.http.HttpStatusCode | ||
| import io.ktor.http.encodeURLPathPart | ||
| import io.ktor.utils.io.jvm.javaio.toInputStream | ||
| import kotlinx.coroutines.CancellationException | ||
| import kotlinx.coroutines.delay | ||
| import org.siloserver.silo.model.download.DownloadMediaType | ||
| import org.siloserver.silo.model.download.OfflineManifestTracks | ||
| import org.siloserver.silo.model.download.OfflineManifestSubtitle | ||
| import org.siloserver.silo.model.download.OfflineSubtitleFile | ||
| import org.siloserver.silo.model.download.OfflineTrackInfo | ||
| import org.siloserver.silo.model.download.decodeOfflineManifestTracks | ||
| import org.siloserver.silo.model.download.isOfflineSubtitleFetchUrl | ||
| import org.siloserver.silo.model.download.offlineSubtitleExtension | ||
| import org.siloserver.silo.model.download.offlineSubtitleFormat | ||
| import org.siloserver.silo.model.download.toOfflineTrackInfo | ||
| import org.siloserver.silo.playback.orNullIfBlank | ||
| import java.io.File | ||
| import java.io.IOException | ||
|
|
||
| /** | ||
| * Captures what offline video playback needs once the media bytes are down: | ||
| * the offline manifest's audio tracks (positions inside the delivered file) and | ||
| * the subtitle sidecars it lists, each fetched once into private storage. | ||
| * | ||
| * Everything here is best effort. A missing manifest, an older server, or a | ||
| * sidecar that fails to fetch never fails the video download; the download | ||
| * then plays with whatever was captured (or the legacy offline behaviour). | ||
| */ | ||
| internal class OfflineTrackAssetFetcher( | ||
| private val httpClient: HttpClient, | ||
| private val storage: DownloadStorage, | ||
| private val manifestRetryDelayMs: Long = 2_000, | ||
| ) { | ||
| suspend fun fetch( | ||
| downloadId: String, | ||
| serverId: String, | ||
| profileId: String, | ||
| fileId: Int, | ||
| configure: HttpRequestBuilder.() -> Unit, | ||
| ): OfflineTrackInfo? { | ||
| val manifest = fetchManifest(downloadId, configure) ?: return null | ||
|
|
||
| val directory = storage.offlineSubtitleDirectory(serverId, profileId, fileId) | ||
| // A replaced download (new revision) must not keep the previous | ||
| // revision's sidecars around under the same file slot. | ||
| directory?.deleteRecursively() | ||
| val saved = if (directory == null || manifest.subtitles.isEmpty()) { | ||
| emptyList() | ||
| } else { | ||
| manifest.subtitles.mapIndexedNotNull { ordinal, subtitle -> | ||
| fetchSubtitle(downloadId, ordinal, subtitle, directory, configure) | ||
| } | ||
| } | ||
| return manifest.toOfflineTrackInfo(saved) | ||
| } | ||
|
|
||
| /** | ||
| * The capture runs once, right after the download completes, so a network | ||
| * blip or a transient server error is retried briefly here; a missing | ||
| * manifest (older server) is not. | ||
| */ | ||
| private suspend fun fetchManifest( | ||
| downloadId: String, | ||
| configure: HttpRequestBuilder.() -> Unit, | ||
| ): OfflineManifestTracks? { | ||
| repeat(MANIFEST_ATTEMPTS) { attempt -> | ||
| if (attempt > 0) delay(manifestRetryDelayMs * attempt) | ||
| try { | ||
| val response = httpClient.get("/api/v2/downloads/${downloadId.encodeURLPathPart()}/manifest") { | ||
| configure() | ||
| } | ||
| val status = response.status | ||
| if (status == HttpStatusCode.OK) return decodeOfflineManifestTracks(response.bodyAsText()) | ||
| Log.i(TAG, "manifest unavailable id=$downloadId status=${status.value}") | ||
| if (status.value < 500 && status != HttpStatusCode.TooManyRequests) return null | ||
| } catch (e: CancellationException) { | ||
| throw e | ||
| } catch (e: Exception) { | ||
| Log.w(TAG, "manifest fetch failed id=$downloadId attempt=${attempt + 1}", e) | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| private suspend fun fetchSubtitle( | ||
| downloadId: String, | ||
| ordinal: Int, | ||
| subtitle: OfflineManifestSubtitle, | ||
| directory: File, | ||
| configure: HttpRequestBuilder.() -> Unit, | ||
| ): OfflineSubtitleFile? { | ||
| val format = offlineSubtitleFormat(subtitle.format) ?: return null | ||
| if (!isOfflineSubtitleFetchUrl(subtitle.fetchUrl)) { | ||
| Log.w(TAG, "skipping subtitle with unexpected reference id=$downloadId ordinal=$ordinal") | ||
| return null | ||
| } | ||
| val target = File(directory, "$ordinal.${offlineSubtitleExtension(format)}") | ||
| val partial = File(directory, "$ordinal.part") | ||
| return try { | ||
| if (!directory.isDirectory && !directory.mkdirs()) throw IOException("could not create $directory") | ||
| httpClient.prepareGet(subtitle.fetchUrl.trim()) { | ||
| configure() | ||
| // Embedded ASS/PGS sidecars are extracted from the source on | ||
| // demand, so the first byte can take a while; keep only an idle | ||
| // timeout, like the media transfer itself. | ||
| timeout { | ||
| requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS | ||
| socketTimeoutMillis = SUBTITLE_IDLE_TIMEOUT_MS | ||
| } | ||
| }.execute { response -> | ||
| if (response.status != HttpStatusCode.OK) { | ||
| throw IOException("HTTP ${response.status.value}") | ||
| } | ||
| var written = 0L | ||
| response.bodyAsChannel().toInputStream().use { input -> | ||
| partial.outputStream().use { output -> | ||
| val buffer = ByteArray(BUFFER_BYTES) | ||
| while (true) { | ||
| val read = input.read(buffer) | ||
| if (read < 0) break | ||
| written += read | ||
| if (written > MAX_SUBTITLE_BYTES) throw IOException("subtitle exceeds $MAX_SUBTITLE_BYTES bytes") | ||
| output.write(buffer, 0, read) | ||
| } | ||
| } | ||
| } | ||
| if (written == 0L) throw IOException("empty subtitle") | ||
| } | ||
| if (!partial.renameTo(target)) throw IOException("could not publish $target") | ||
| OfflineSubtitleFile( | ||
| path = target.absolutePath, | ||
| format = format, | ||
| language = subtitle.language.orNullIfBlank(), | ||
| title = subtitle.title.orNullIfBlank(), | ||
| forced = subtitle.forced, | ||
| hearingImpaired = subtitle.hearingImpaired, | ||
| ) | ||
| } catch (e: CancellationException) { | ||
| partial.delete() | ||
| throw e | ||
| } catch (e: Exception) { | ||
| Log.w(TAG, "subtitle fetch failed id=$downloadId ordinal=$ordinal format=$format", e) | ||
| partial.delete() | ||
| target.delete() | ||
| null | ||
| } | ||
| } | ||
|
|
||
| companion object { | ||
| private const val TAG = "OfflineTrackAssets" | ||
| private const val MANIFEST_ATTEMPTS = 3 | ||
| private const val BUFFER_BYTES = 64 * 1024 | ||
| private const val SUBTITLE_IDLE_TIMEOUT_MS = 120_000L | ||
|
|
||
| /** PGS tracks for a feature run to tens of MB; text is far smaller. */ | ||
| private const val MAX_SUBTITLE_BYTES = 256L * 1024 * 1024 | ||
|
|
||
| /** Only video downloads have tracks to capture. */ | ||
| fun appliesTo(mediaType: String?): Boolean = | ||
| when (DownloadMediaType.fromWire(mediaType)) { | ||
| DownloadMediaType.Movie, DownloadMediaType.TvShow, DownloadMediaType.Unknown -> true | ||
| DownloadMediaType.Audiobook, DownloadMediaType.Ebook -> false | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Clear old offline tracks when publishing replacement bytes.
If a new revision replaces a download in the same file slot,
writeSidecarStatusretains the previousofflineTracks. If manifest capture then fails, the completed file keeps the previous revision’s audio and subtitle choices. A later worker attempt also skips capture becauseofflineTracksis non-null. Clear the track data when publishing replacement bytes, then store the newly captured tracks only after capture succeeds.🤖 Prompt for AI Agents