From 638a73f692d046ed05b1fadc3b9c0b8058631187 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:39:38 -0400 Subject: [PATCH 01/10] feat(downloads): label download presets with their resolution (#415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(downloads): label download presets with their resolution Server download presets now pair each bitrate with a resolution ceiling and report it in the capability's quality_options. The quality picker and the Settings default read those labels ("10 Mbps · up to 1080p"), and the Settings list now offers only the presets this account may use. Download creation also sends the device's decode caps, built from the same MediaCodec probe playback uses, so the server keeps a preset's resolution within what this phone decodes and serves an original that already fits instead of re-encoding it. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): fall back from a disallowed default and describe outcomes A saved default quality this account can no longer request now falls back to Original when a download starts, as the iOS app does, instead of a request the server refuses. The quality sheet's descriptions no longer promise a specific file: a preset is prepared only when the original is larger, and Original may still get a compatible copy. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): re-decide unstarted entries with caps and honor the DV setting The create path returned a matching entry without posting, so an entry an earlier build created without caps was never decided for this device. Keep that shortcut only for entries whose bytes are on or coming to the device; post the others with the current caps. The server keeps an entry unchanged when the decision is the same, so this restarts nothing that has started. Download caps now apply the Dolby Vision setting to their HDR claims, the same way the streaming caps do. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(settings): show the download default new downloads will use When the saved default is a preset the account can no longer request, new downloads fall back to Original, but Settings still showed the saved preset, which the list no longer offered. Share that fallback between the enqueuer and Settings so the shown default is the one downloads use. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(downloads): fall back to an offered preset when Original is not listed The capability contract only promises that clients offer values from quality_presets, not that Original is among them. When the saved default is unavailable, use Original if offered and otherwise the first offered preset, so the request never names a preset the server did not list. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../silo/common/downloads/DownloadEnqueuer.kt | 19 +++++- .../player/PlaybackCapabilityDetector.kt | 28 ++++++++ .../silo/android/di/AndroidModule.kt | 4 +- .../detail/DownloadQualityPickerSheet.kt | 9 ++- .../ui/screens/detail/ItemDetailScreen.kt | 2 + .../ui/screens/settings/SettingsScreen.kt | 2 +- .../ui/screens/settings/SettingsViewModel.kt | 31 +++++++-- .../silo/model/download/DownloadModels.kt | 68 +++++++++++++++++++ .../network/apiv2/DownloadCreationV2Api.kt | 12 +++- .../model/download/DownloadCapabilityTest.kt | 43 ++++++++++++ .../network/apiv2/DownloadCreationV2Test.kt | 53 +++++++++++++++ 11 files changed, 255 insertions(+), 16 deletions(-) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt index d031ca80a..c24606aaf 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt @@ -3,8 +3,11 @@ package org.siloserver.silo.common.downloads import android.content.Context import android.util.Log import androidx.work.WorkManager +import org.siloserver.silo.common.player.PlaybackCapabilityDetector import org.siloserver.silo.common.settings.PlayerSettingsStore +import org.siloserver.silo.common.settings.dolbyVisionPolicySnapshot import org.siloserver.silo.model.download.DownloadQuality +import org.siloserver.silo.model.download.effectiveDefault import org.siloserver.silo.model.download.DownloadMediaType import org.siloserver.silo.model.download.DownloadRequest import org.siloserver.silo.model.download.DownloadSidecar @@ -46,6 +49,8 @@ class DownloadEnqueuer( private val authorities: org.siloserver.silo.network.DurableLoginAuthorityProvider? = null, private val transitions: org.siloserver.silo.network.IdentityTransitionBarrier? = null, private val devices: org.siloserver.silo.network.DeviceMetadataProvider? = null, + /** Supplies the decode caps sent with each create; null sends none. */ + private val capabilityDetector: PlaybackCapabilityDetector? = null, ) { /** @@ -373,7 +378,10 @@ class DownloadEnqueuer( series: Boolean = false, downloadQualityOverride: DownloadQuality? = null, ): DownloadRequest { - val quality = downloadQualityOverride ?: DownloadQuality.fromWire(playerSettingsStore.defaultDownloadQualityFlow.first()) + val quality = downloadQualityOverride + ?: repository.capability.value.effectiveDefault( + DownloadQuality.fromWire(playerSettingsStore.defaultDownloadQualityFlow.first()), + ) return DownloadRequest( contentId = contentId, episodeId = episodeId, @@ -381,6 +389,15 @@ class DownloadEnqueuer( series = series, quality = quality.wire, targetBitrateKbps = quality.targetBitrateKbps, + // A failed probe must not block the download; the server then + // treats the device as able to play the original. + caps = capabilityDetector?.let { detector -> + withContext(Dispatchers.Default) { + runCatching { detector.downloadCaps(dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot()) } + .onFailure { Log.w(TAG, "download caps probe failed", it) } + .getOrNull() + } + }, ) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt index f57c09042..745194645 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackCapabilityDetector.kt @@ -15,6 +15,7 @@ import androidx.media3.common.util.UnstableApi import org.siloserver.silo.common.network.SiloClientBuildIdentity import org.siloserver.silo.player.DolbyVisionPolicy import org.siloserver.silo.common.player.video.media3OriginalPlaybackContainers +import org.siloserver.silo.model.download.DownloadCaps import org.siloserver.silo.model.playback.ClientPlaybackContext import org.siloserver.silo.model.playback.ClientCodecCapabilities import org.siloserver.silo.model.playback.CAPABILITY_EVIDENCE_EXACT @@ -404,6 +405,33 @@ class PlaybackCapabilityDetector( return detected } + /** + * Decode-only capability for `POST /api/v2/downloads`. A download plays + * later on whatever output is attached then, so this omits the display + * and audio-route facts [detect] intersects in: the codec, container and + * resolution facts come from the same probes, and HDR is the decoder's own + * support after the same Dolby Vision setting [detect] applies. + */ + fun downloadCaps( + ffmpegAvailable: Boolean = FfmpegAudioSupport.isAvailable(), + dolbyVision: DolbyVisionPolicy.Snapshot = DolbyVisionPolicy.Snapshot(), + ): DownloadCaps { + val codecProbe = MediaCodecCapabilitiesProbe.probe() + val decoderHdr = codecProbe.hdr.withDolbyVisionPolicy(dolbyVision) + return DownloadCaps( + videoEvidence = CAPABILITY_EVIDENCE_EXACT, + codecsVideo = codecProbe.videoCodecs.toList(), + codecsAudio = advertisedAudioDecodeCodecs( + platformCodecs = detectPlatformSoftwareAudioCodecs().codecs, + ffmpegCodecs = if (ffmpegAvailable) FfmpegAudioSupport.supportedCodecShortCodes() else emptyList(), + ), + containers = media3OriginalPlaybackContainers, + maxResolution = codecProbe.maxResolution, + hdr = decoderHdr.hdr10 || decoderHdr.hdr10Plus || decoderHdr.hlg || decoderHdr.dolbyVisionProfiles.isNotEmpty(), + videoDecode = codecProbe.videoDecodeCapabilities, + ) + } + /** * The form factor implied by the current UI mode, for callers that live in * `android-shared` and so cannot see either app's `BuildConfig`. The app diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index d91ae3654..1f4b53f7c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -315,7 +315,7 @@ val androidModule = module { // One-time import of the legacy .record.json sidecar tree into Room. single { org.siloserver.silo.common.downloads.LegacyDownloadImporter(androidContext().filesDir, get()) } single { OfflineMediaResolver(get(), get(), get()) } - single { DownloadEnqueuer(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + single { DownloadEnqueuer(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { DownloadSubscriptionEvaluatorFactory(get(), get(), get()) } // CoroutineWorker constructed by Koin's WorkerFactory — see // SiloApplication.onCreate `workManagerFactory()` call. @@ -453,7 +453,7 @@ val androidModule = module { tmdbId = args.second, ) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { org.siloserver.silo.android.ui.screens.pairing.CompanionPairingViewModel(get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DownloadQualityPickerSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DownloadQualityPickerSheet.kt index 0c69e083a..1944b4110 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DownloadQualityPickerSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/DownloadQualityPickerSheet.kt @@ -39,6 +39,9 @@ fun DownloadQualityPickerSheet( // Presets to offer, already gated by server capability + media type (issue // #20 GAP 4). Empty falls back to Original so the sheet is never blank. allowedQualities: List = DownloadQuality.entries, + // Row title per preset; the caller passes the server capability's label, + // which adds the preset's resolution ceiling ("10 Mbps · up to 1080p"). + qualityLabel: (DownloadQuality) -> String = { it.label }, ) { val qualities = allowedQualities.ifEmpty { listOf(DownloadQuality.Original) } ModalBottomSheet( @@ -89,7 +92,7 @@ fun DownloadQualityPickerSheet( ListItem( headlineContent = { Text( - text = quality.label, + text = qualityLabel(quality), style = MaterialTheme.typography.bodyLarge, ) }, @@ -165,6 +168,6 @@ internal fun downloadEstimateWarning( private fun downloadQualityDescription(quality: DownloadQuality): String = when (quality) { - DownloadQuality.Original -> "Original file, best quality, largest download." - else -> "Smaller copy capped around ${quality.label}." + DownloadQuality.Original -> "Source quality, with compatibility fallback if needed." + else -> "Prepared on the server when the original is larger." } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index 98aa253ef..62a71ee1a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -79,6 +79,7 @@ import org.siloserver.silo.model.ebook.chooseEbookVersion import org.siloserver.silo.model.ebook.isInAppReadableEbookVersion import org.siloserver.silo.model.ebook.isSupportedEbookVersion import org.siloserver.silo.model.download.DownloadQuality +import org.siloserver.silo.model.download.labelFor import org.siloserver.silo.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.network.ServerRegistry @@ -1002,6 +1003,7 @@ fun ItemDetailScreen( estimate = pendingDownloadEstimate, availableBytes = remember { downloadStorage.usableSpaceBytes() }, allowedQualities = pendingDownloadAllowedQualities, + qualityLabel = { quality -> downloadCapability.labelFor(quality) }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt index 6d03a4c0f..188f24b9c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsScreen.kt @@ -370,7 +370,7 @@ fun SettingsScreen( label = "Download quality", description = "Quality preset used for new downloads.", value = state.defaultDownloadQuality, - options = DownloadQuality.entries.map { it.label }, + options = state.downloadQualityOptions, onOptionSelected = viewModel::setDefaultDownloadQuality, ) SettingsSwitchRow( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt index 91c89466f..7156f87a0 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt @@ -16,6 +16,9 @@ import org.siloserver.silo.domain.player.IntroSkipMode import org.siloserver.silo.domain.settings.ProfileSettingsController import org.siloserver.silo.model.auth.User import org.siloserver.silo.model.download.DownloadQuality +import org.siloserver.silo.model.download.effectiveDefault +import org.siloserver.silo.model.download.labelFor +import org.siloserver.silo.repository.DownloadsRepository import org.siloserver.silo.model.notifications.NotificationPreferencesUpdate import org.siloserver.silo.model.settings.CardCaption import org.siloserver.silo.model.settings.CardPosterSize @@ -94,6 +97,9 @@ data class SettingsUiState( val downloadsWifiOnly: Boolean = true, val keepWatchedDownloads: Boolean = false, val defaultDownloadQuality: String = DownloadQuality.Original.label, + // The presets this account may request, labelled with the server's + // resolution ceiling once the download capability has loaded. + val downloadQualityOptions: List = DownloadQuality.entries.map { it.label }, // Subtitles // BCP 47 tag, "" = off. The picker converts to and from labels. @@ -131,6 +137,7 @@ class SettingsViewModel( private val cardPresentationStore: CardPresentationStore, private val seekIntervalStore: SeekIntervalStore, audiobookSettingsStore: AudiobookSettingsStore, + private val downloadsRepository: DownloadsRepository? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -233,9 +240,20 @@ class SettingsViewModel( playerSettingsStore.keepWatchedDownloadsFlow.onEach { keepWatched -> _uiState.update { it.copy(keepWatchedDownloads = keepWatched) } }.launchIn(viewModelScope) - playerSettingsStore.defaultDownloadQualityFlow.onEach { quality -> - _uiState.update { it.copy(defaultDownloadQuality = downloadQualityLabel(quality)) } + val downloadCapability = downloadsRepository?.capability ?: MutableStateFlow(null) + combine(playerSettingsStore.defaultDownloadQualityFlow, downloadCapability) { quality, capability -> + val offered = capability?.allowedQualities() ?: DownloadQuality.entries + _uiState.update { + it.copy( + // The preset new downloads will actually use. + defaultDownloadQuality = capability.labelFor(capability.effectiveDefault(DownloadQuality.fromWire(quality))), + downloadQualityOptions = offered.map { preset -> capability.labelFor(preset) }, + ) + } }.launchIn(viewModelScope) + // Opening Settings refreshes the capability, as the detail screen does, + // so the labels reflect the server's current download settings. + downloadsRepository?.let { repository -> viewModelScope.launch { repository.refreshCapability() } } playerSettingsStore.pictureInPictureEnabledFlow.onEach { enabled -> _uiState.update { it.copy(pictureInPictureEnabled = enabled) } }.launchIn(viewModelScope) @@ -627,9 +645,10 @@ class SettingsViewModel( } } - private fun downloadQualityLabel(value: String): String = - DownloadQuality.fromWire(value).label - + // A shown label is the preset's bitrate label plus an optional + // " · up to …" suffix, so the bitrate part alone identifies the preset + // even if the capability refreshed after the list was drawn. private fun downloadQualityWireValue(value: String): String = - DownloadQuality.entries.firstOrNull { it.label == value }?.wire ?: DownloadQuality.Original.wire + DownloadQuality.entries.firstOrNull { it.label == value.substringBefore(" · ") }?.wire + ?: DownloadQuality.Original.wire } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt index e694aca86..4ed9d14c0 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt @@ -73,12 +73,53 @@ enum class DownloadQuality( Mbps2("2mbps", "2 Mbps", 2_000), Mbps1("1mbps", "1 Mbps", 1_000); + /** + * [label] plus the resolution ceiling the server reports for this preset, + * e.g. "20 Mbps · up to 4K". A missing ceiling (Original, or an older + * server) leaves the bitrate-only label. + */ + fun label(maxHeight: Int?): String { + if (maxHeight == null || maxHeight <= 0) return label + val resolution = if (maxHeight >= 2160) "4K" else "${maxHeight}p" + return "$label · up to $resolution" + } + companion object { fun fromWire(value: String?): DownloadQuality = entries.firstOrNull { it.wire == value?.lowercase()?.trim() } ?: Original } } +/** + * One entry of the capability's `quality_options`: a preset's video bitrate + * cap and the tallest output it can produce on this server. Both are absent + * for `original`. + */ +@Serializable +data class DownloadQualityOption( + val preset: String, + @SerialName("bitrate_kbps") val bitrateKbps: Int? = null, + @SerialName("max_height") val maxHeight: Int? = null, +) + +/** + * Decode-only device facts sent as `caps` on `POST /api/v2/downloads`. The + * server uses them to serve `original` as-is only when this device plays it, + * and to keep a bitrate preset's resolution within what the decoder takes. + * A download plays later on whatever output is attached then, so nothing here + * describes the current display or audio route. + */ +@Serializable +data class DownloadCaps( + @SerialName("video_evidence") val videoEvidence: String, + @SerialName("codecs_video") val codecsVideo: List, + @SerialName("codecs_audio") val codecsAudio: List, + val containers: List, + @SerialName("max_resolution") val maxResolution: String? = null, + val hdr: Boolean = false, + @SerialName("video_decode") val videoDecode: List = emptyList(), +) + /** * POST /api/v2/downloads body. Either `episodeId` or `fileId` is set on * top of the always-required `contentId`. `series = true` requests batch @@ -96,6 +137,7 @@ data class DownloadRequest( @SerialName("device_id") val deviceId: String? = null, val revision: Int? = null, @SerialName("status_event_at") val statusEventAt: String? = null, + val caps: DownloadCaps? = null, ) /** @@ -158,12 +200,18 @@ data class DownloadCapability( val enabled: Boolean = false, @SerialName("download_allowed") val downloadAllowed: Boolean = false, @SerialName("quality_presets") val qualityPresets: List = emptyList(), + /** One entry per [qualityPresets] value: its bitrate cap and resolution ceiling. */ + @SerialName("quality_options") val qualityOptions: List = emptyList(), @SerialName("transcode_enabled") val transcodeEnabled: Boolean = false, @SerialName("transcode_user_allowed") val transcodeUserAllowed: Boolean = false, @SerialName("season_download") val seasonDownload: Boolean = false, @SerialName("series_monitoring") val seriesMonitoring: Boolean = false, @SerialName("monitoring_modes") val monitoringModes: List = emptyList(), ) { + /** The label for [quality], with the server's resolution ceiling: "10 Mbps · up to 1080p". */ + fun label(quality: DownloadQuality): String = + quality.label(qualityOptions.firstOrNull { it.preset == quality.wire }?.maxHeight) + /** Downloads are usable only when the feature is on AND this user may download. */ val isUsable: Boolean get() = enabled && downloadAllowed && (revision == null || (revision.isNotBlank() && state == "available")) @@ -186,6 +234,26 @@ data class DownloadCapability( } } +/** [DownloadCapability.label], or the bitrate-only label before a capability has loaded. */ +fun DownloadCapability?.labelFor(quality: DownloadQuality): String = this?.label(quality) ?: quality.label + +/** + * The preset new downloads use for a saved default: the saved one when this + * capability offers it, since the server refuses a preset the account can no + * longer request (transcoding turned off, say); else Original, or the first + * offered preset if the server does not list Original. An unloaded capability + * keeps the saved value. + */ +fun DownloadCapability?.effectiveDefault(saved: DownloadQuality): DownloadQuality { + if (this == null) return saved + val offered = allowedQualities() + return when { + saved in offered -> saved + DownloadQuality.Original in offered -> DownloadQuality.Original + else -> offered.first() + } +} + /** Convenience: type-safe accessor. */ fun DownloadRecord.statusEnum(): DownloadStatus = DownloadStatus.fromWire(status) diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadCreationV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadCreationV2Api.kt index b51a358db..610ca192b 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadCreationV2Api.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadCreationV2Api.kt @@ -34,6 +34,7 @@ class DownloadCreationV2Api( request.episodeId?.let { put("episode_id",it) } request.fileId?.let { put("media_file_id",it.toString()) } put("quality", request.quality ?: "original") + request.caps?.let { put("caps", SiloJson.encodeToJsonElement(DownloadCaps.serializer(), it)) } }.toMutableMap() private suspend fun send(scope: AuthScopeSnapshot, device: String, body: JsonObject, cursor: String? = null): ApiResult { @@ -56,8 +57,12 @@ class DownloadCreationV2Api( val device = devices.current()?.id?.takeIf { it.isNotBlank() } ?: return identityChanged() if (!current(scope,device)) return identityChanged() if (request.series || request.fileId == null || request.fileId <= 0) return invalid() - // Reconcile first. A confirmed usable entry with the requested quality - // can be downloaded without resetting its bytes/status/batch on the server. + // Reconcile first. A usable entry with the requested quality whose + // bytes are already on or coming to this device is downloaded without + // resetting its bytes/status/batch on the server. One no bytes have + // reached yet is posted again with the current caps, so an entry an + // earlier build created without them is decided for this device; the + // server keeps the entry unchanged when the decision is the same. val listed = registry.list(scope) if (listed !is ApiResult.Success) return when(listed) { is ApiResult.Error -> listed @@ -74,7 +79,8 @@ class DownloadCreationV2Api( if (matches.size > 1) return invalid() val existing = matches.singleOrNull() if (existing != null && existing.mediaFileId == request.fileId && existing.quality == (request.quality ?: "original") && - existing.status in setOf("ready","preparing","queued","downloading","completed")) return ApiResult.Success(existing) + existing.status in setOf("ready","preparing","queued","downloading","completed") && + (request.caps == null || existing.status in setOf("downloading","completed") || existing.bytesSent > 0)) return ApiResult.Success(existing) val fields = body(request) fields["expected_revision"] = JsonPrimitive(existing?.revision ?: 0) existing?.let { fields["expected_download_id"] = JsonPrimitive(it.id) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/DownloadCapabilityTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/DownloadCapabilityTest.kt index 5cc06bc07..b98050f56 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/DownloadCapabilityTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/DownloadCapabilityTest.kt @@ -31,6 +31,30 @@ class DownloadCapabilityTest { assertTrue(cap.transcodeEnabled) } + @Test + fun `labels presets with the server resolution ceiling`() { + val source = """ + { + "enabled": true, + "download_allowed": true, + "quality_presets": ["original", "20mbps", "1mbps"], + "quality_options": [ + {"preset": "original"}, + {"preset": "20mbps", "bitrate_kbps": 20000, "max_height": 2160}, + {"preset": "1mbps", "bitrate_kbps": 1000, "max_height": 480} + ] + } + """.trimIndent() + + val cap = json.decodeFromString(source) + assertEquals("Original", cap.labelFor(DownloadQuality.Original)) + assertEquals("20 Mbps · up to 4K", cap.labelFor(DownloadQuality.Mbps20)) + assertEquals("1 Mbps · up to 480p", cap.labelFor(DownloadQuality.Mbps1)) + // A preset the server does not describe, or no capability yet, keeps the bitrate label. + assertEquals("10 Mbps", cap.labelFor(DownloadQuality.Mbps10)) + assertEquals("5 Mbps", (null as DownloadCapability?).labelFor(DownloadQuality.Mbps5)) + } + @Test fun `allowedQualities offers every preset when transcode is enabled and allowed`() { val cap = DownloadCapability( @@ -43,6 +67,25 @@ class DownloadCapabilityTest { assertEquals(DownloadQuality.entries.toList(), cap.allowedQualities()) } + @Test + fun `a saved default the account can no longer request falls back to Original`() { + val transcodeOff = DownloadCapability( + enabled = true, + downloadAllowed = true, + qualityPresets = listOf("original"), + transcodeEnabled = false, + transcodeUserAllowed = true, + ) + assertEquals(DownloadQuality.Original, transcodeOff.effectiveDefault(DownloadQuality.Mbps10)) + val transcodeOn = transcodeOff.copy(qualityPresets = listOf("original", "10mbps"), transcodeEnabled = true) + assertEquals(DownloadQuality.Mbps10, transcodeOn.effectiveDefault(DownloadQuality.Mbps10)) + // Before the capability loads, the saved value stands. + assertEquals(DownloadQuality.Mbps10, (null as DownloadCapability?).effectiveDefault(DownloadQuality.Mbps10)) + // Without Original on offer, the first offered preset is used. + val bitrateOnly = transcodeOn.copy(qualityPresets = listOf("5mbps", "2mbps")) + assertEquals(DownloadQuality.Mbps5, bitrateOnly.effectiveDefault(DownloadQuality.Mbps10)) + } + @Test fun `allowedQualities collapses to Original when transcode is disabled`() { val cap = DownloadCapability( diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadCreationV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadCreationV2Test.kt index c9ffdbec5..32bd94dad 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadCreationV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadCreationV2Test.kt @@ -68,6 +68,29 @@ class DownloadCreationV2Test { } finally { c.close() } } + @Test fun createSendsDecodeCapsForTheServerToFitTheDownload() = runTest { + var caps: JsonObject? = null + val c = client { + if (it.method == HttpMethod.Get) json("""{"items":[],"page":{"has_more":false}}""") else { + caps = SiloJson.parseToJsonElement(it.body.toByteArray().decodeToString()).jsonObject["caps"]?.jsonObject + json("""{"items":[$row],"skipped":[],"page":{"has_more":false}}""",HttpStatusCode.Accepted) + } + } + try { + val decode = org.siloserver.silo.model.playback.VideoDecodeCapability(codec = "h264", bitDepths = listOf(8), maxWidth = 1920, maxHeight = 1080, hardware = true) + val request = DownloadRequest("movie", fileId = 42, caps = DownloadCaps("exact", listOf("h264"), listOf("aac"), listOf("mp4", "mkv"), "1080p", false, listOf(decode))) + assertIs>(api(c).create(request, scope)) + val sent = assertNotNull(caps) + assertEquals(JsonPrimitive("exact"), sent["video_evidence"]) + assertEquals(JsonPrimitive("1080p"), sent["max_resolution"]) + val entry = sent["video_decode"]!!.jsonArray.single().jsonObject + assertEquals(JsonPrimitive(1920), entry["max_width"]) + // The server rejects fields outside its caps schema, and null for an integer. + assertEquals(setOf("video_evidence", "codecs_video", "codecs_audio", "containers", "max_resolution", "hdr", "video_decode"), sent.keys) + assertFalse(entry.values.any { value -> value is JsonNull }) + } finally { c.close() } + } + @Test fun matchingEntryReconcilesWithoutResetAnd401HasNoReplay() = runTest { var posts = 0 val c = client { @@ -84,6 +107,36 @@ class DownloadCreationV2Test { } finally { c.close() } } + @Test fun reusableEntryIsPostedWithCapsUntilBytesReachTheDevice() = runTest { + var listed = row + var posts = 0 + val c = client { + if (it.method == HttpMethod.Get) json("""{"items":[$listed],"page":{"has_more":false}}""") else { + posts++ + val body = SiloJson.parseToJsonElement(it.body.toByteArray().decodeToString()).jsonObject + assertNotNull(body["caps"]) + assertEquals(JsonPrimitive(7), body["expected_revision"]); assertEquals(JsonPrimitive("entry"), body["expected_download_id"]) + json("""{"items":[$listed],"skipped":[],"page":{"has_more":false}}""",HttpStatusCode.Accepted) + } + } + try { + val api = api(c) + val caps = DownloadCaps("exact", listOf("h264"), listOf("aac"), listOf("mp4"), "1080p", false, emptyList()) + // A ready entry no bytes have reached is re-decided with the caps. + assertIs>(api.create(DownloadRequest("movie",fileId=42,caps=caps),scope)) + assertEquals(1,posts) + // Without caps there is nothing new to send. + assertIs>(api.create(DownloadRequest("movie",fileId=42),scope)) + assertEquals(1,posts) + // Bytes already sent, or a completed entry, are never re-decided. + listed = row.replace("\"bytes_sent\":0", "\"bytes_sent\":50") + assertIs>(api.create(DownloadRequest("movie",fileId=42,caps=caps),scope)) + listed = row.replace("\"status\":\"ready\"", "\"status\":\"completed\"") + assertIs>(api.create(DownloadRequest("movie",fileId=42,caps=caps),scope)) + assertEquals(1,posts) + } finally { c.close() } + } + @Test fun batchContinuesEmptyPageAndPreservesSkippedAndOldBatch() = runTest { var firstBody: JsonObject? = null var calls = 0 From 8fad2282729649514f82cdac897c21ed05499071 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:55:15 -0400 Subject: [PATCH 02/10] fix(calendar): keep premiere badges on one line (#402) The series-premiere badge wrapped after "SERIES" on phone, tablet and TV, drew only that first word, and left the rest of the pill blank. Label it "PREMIERE", which fits a poster at the current badge sizes, and stop badge text from wrapping so an over-long label ends in an ellipsis instead of a blank pill. NEW SEASON still marks season premieres. Closes #397 Co-authored-by: Claude Opus 5.5 (1M context) --- .../silo/android/ui/screens/calendar/CalendarScreen.kt | 9 +++++++-- .../silo/tv/ui/screens/calendar/TvCalendarScreen.kt | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt index 91ce28c0e..f1552fc73 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt @@ -800,6 +800,9 @@ private fun BadgePill(label: String) { fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp, // iOS tracking 0.8 maxLines = 1, + // Wrapping drew only the first word in a full-width pill. + softWrap = false, + overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.background, ) } @@ -863,8 +866,10 @@ private fun EmptyState(filter: String, onShowEverything: () -> Unit) { } private fun badgeLabel(badge: String): String? = when (badge) { - // iOS CalendarBadge labels (uppercased editorial). - CalendarBadge.SeriesPremiere -> "SERIES PREMIERE" + // iOS CalendarBadge labels (uppercased editorial). iOS says "SERIES + // PREMIERE", which does not fit a poster at the 11sp badge floor; + // NEW SEASON already marks season premieres. + CalendarBadge.SeriesPremiere -> "PREMIERE" CalendarBadge.SeasonPremiere -> "NEW SEASON" CalendarBadge.Finale -> "FINALE" else -> null diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt index b36b060bf..2f45e1b2c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt @@ -1561,6 +1561,9 @@ private fun BadgePill(text: String) { fontWeight = FontWeight.Bold, color = Color.Black, maxLines = 1, + // Wrapping drew only the first word in a full-width pill. + softWrap = false, + overflow = TextOverflow.Ellipsis, ) } } @@ -1650,7 +1653,9 @@ private fun CalendarMessage( // MARK: - Formatting helpers private fun badgeLabel(badge: String): String? = when (badge) { - CalendarBadge.SeriesPremiere -> "SERIES PREMIERE" + // tvOS says "SERIES PREMIERE", which does not fit a poster at 14sp; + // NEW SEASON already marks season premieres. + CalendarBadge.SeriesPremiere -> "PREMIERE" CalendarBadge.SeasonPremiere -> "NEW SEASON" CalendarBadge.Finale -> "FINALE" else -> null From 9174d7c047cdc9021373ea05a5dbf07d00f4b624 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:55:29 -0400 Subject: [PATCH 03/10] fix(calendar): link an empty week to the other two views (#403) * fix(calendar): link an empty week to the other two views The empty Calendar state offered only Show Everything, and only in Following and Trending. All offered nothing on phone and tablet and Refresh on TV, and Trending reused the generic title. The empty state now links to the other two views, never the current one, and Trending gets its own title. On TV the links sit in a row of focusable buttons, and pressing one moves focus to the new view's filter chip instead of letting it fall through to the top menu. Closes #398 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(calendar): wrap phone empty-state links on narrow screens A saved legacy filter such as favorites gets all three links, and three 140 dp buttons in a plain Row need 444 dp, so the last one fell off a narrow phone. A large font scale could crowd two buttons the same way. The links now sit in a centered FlowRow that wraps onto a second line. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(calendar): let phone empty-state buttons grow with their label The buttons were fixed at 140 dp, so at a large font scale "Following" and "Trending" were clipped even though the row now wraps. They keep 140 dp as a minimum and widen to fit the label. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../ui/screens/calendar/CalendarScreen.kt | 69 ++++++++---- .../ui/screens/calendar/TvCalendarScreen.kt | 100 +++++++++++------- 2 files changed, 111 insertions(+), 58 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt index f1552fc73..41ad2f6a4 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/calendar/CalendarScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -23,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -218,7 +221,7 @@ fun CalendarScreen( !state.hasAnyItems -> item(key = "empty") { EmptyState( filter = state.filter, - onShowEverything = { viewModel.setFilter(CalendarFilter.Everything) }, + onSelectFilter = viewModel::setFilter, ) } else -> items(state.weekDates, key = { "day-$it" }) { date -> @@ -810,14 +813,27 @@ private fun BadgePill(label: String) { // MARK: - Empty state +/** + * The views an empty week links to: always the other two, never the one on + * screen (silo-server #1494, silo-apple #513). "all" is the legacy spelling of + * Everything; any other legacy filter links to all three views. + */ +private fun emptyStateLinks(filter: String): List> { + val view = if (filter == CalendarFilter.All) CalendarFilter.Everything else filter + return listOf( + CalendarFilter.Following to "Following", + CalendarFilter.Trending to "Trending", + CalendarFilter.Everything to "All", + ).filter { (value, _) -> value != view } +} + /** * iOS empty state: 44pt calendar glyph at `onSurface 0.3`, subheadline title, - * caption body, and a 220pt "Show Everything" primary button whenever the - * filter is narrower than Everything. + * caption body, and primary buttons linking to the other two views. */ +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun EmptyState(filter: String, onShowEverything: () -> Unit) { - val isEverything = filter == CalendarFilter.Everything || filter == CalendarFilter.All +private fun EmptyState(filter: String, onSelectFilter: (String) -> Unit) { Column( modifier = Modifier .fillMaxWidth() @@ -833,11 +849,7 @@ private fun EmptyState(filter: String, onShowEverything: () -> Unit) { modifier = Modifier.size(44.dp), ) Text( - text = if (filter == CalendarFilter.Following) { - "Nothing from shows you follow" - } else { - "Nothing scheduled this week" - }, + text = emptyTitle(filter), fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -849,17 +861,25 @@ private fun EmptyState(filter: String, onShowEverything: () -> Unit) { modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) - if (!isEverything) { - Button( - onClick = onShowEverything, - modifier = Modifier.width(220.dp), - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.onSurface, - contentColor = MaterialTheme.colorScheme.background, - ), - ) { - Text("Show Everything", fontWeight = FontWeight.SemiBold) + // Wraps so a legacy filter's three links, or a large font scale, + // never push a button off a narrow phone. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + emptyStateLinks(filter).forEach { (value, label) -> + Button( + onClick = { onSelectFilter(value) }, + modifier = Modifier.widthIn(min = 140.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.onSurface, + contentColor = MaterialTheme.colorScheme.background, + ), + ) { + Text(label, fontWeight = FontWeight.SemiBold, maxLines = 1) + } } } } @@ -917,8 +937,15 @@ private fun cardSubtitle(item: CalendarItem): String? { return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") } +private fun emptyTitle(filter: String): String = when (filter) { + CalendarFilter.Following -> "Nothing from shows you follow" + CalendarFilter.Trending -> "Nothing trending this week" + else -> "Nothing scheduled this week" +} + private fun emptySubtitle(filter: String): String = when (filter) { CalendarFilter.Following -> "No upcoming releases this week from shows you watch, favorite, or watchlist." + CalendarFilter.Trending -> "No trending releases this week." else -> "No movie releases or episode airings in this week." } diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt index 2f45e1b2c..e818e1b95 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/calendar/TvCalendarScreen.kt @@ -135,8 +135,8 @@ import org.siloserver.silo.viewmodel.CalendarViewModel * "Monday, June 9") over a HORIZONTAL row (LazyRow) of portrait poster * cards. Event-less days render a "Nothing scheduled" stub so the week keeps * its shape and every day is a scroll target. - * - Whole-screen empty state with a focusable action ("Show Everything" when - * filtered, else "Refresh") and a filter-aware title. + * - Whole-screen empty state with focusable links to the other two views and + * a view-specific title. * * Mirrors [org.siloserver.silo.tv.ui.screens.recommendations.TvRecommendationsScreen] * for the koinViewModel + initial-focus-once pattern. @@ -488,7 +488,16 @@ fun TvCalendarScreen( } }, onRefresh = viewModel::refresh, - onShowEverything = { viewModel.setFilter(CalendarFilter.All) }, + onSelectFilter = { filter -> + viewModel.setFilter(filter) + // The pressed link leaves with the old empty state; move + // focus to the new view's filter chip so it doesn't fall + // through to the top menu. + filterFocusRequesters[filter]?.claimFocusOrReport( + target = "calendar_filter", + action = "empty_state_link", + ) + }, selectedDayFocusRequester = selectedDayFocusRequester, shelfFocusDay = shelfFocusDay, shelfFocusRequest = shelfFocusRequest, @@ -1009,7 +1018,7 @@ private fun CalendarList( onControlFocused: (CalendarControlFocusZone?) -> Unit, onFocusRequestAcknowledged: () -> Unit, onRefresh: () -> Unit, - onShowEverything: () -> Unit, + onSelectFilter: (String) -> Unit, selectedDayFocusRequester: FocusRequester, shelfFocusDay: String?, shelfFocusRequest: Int, @@ -1167,7 +1176,7 @@ private fun CalendarList( CalendarMessage( title = state.error ?: "Failed to load calendar", subtitle = "Press the week arrows to try another week.", - action = CalendarAction("Refresh", onRefresh), + actions = listOf(CalendarAction("Refresh", onRefresh)), onActionFocused = clearControlFocusZone, ) } @@ -1177,10 +1186,8 @@ private fun CalendarList( CalendarMessage( title = emptyTitle(state.filter), subtitle = emptyCopy(state.filter), - action = if (state.filter != CalendarFilter.All) { - CalendarAction("Show Everything", onShowEverything) - } else { - CalendarAction("Refresh", onRefresh) + actions = emptyStateLinks(state.filter).map { (value, label) -> + CalendarAction(label) { onSelectFilter(value) } }, topAligned = true, onActionFocused = clearControlFocusZone, @@ -1577,7 +1584,7 @@ private data class CalendarAction(val label: String, val onClick: () -> Unit) private fun CalendarMessage( title: String, subtitle: String, - action: CalendarAction, + actions: List, topAligned: Boolean = false, onActionFocused: () -> Unit, ) { @@ -1616,34 +1623,38 @@ private fun CalendarMessage( color = Color.White.copy(alpha = 0.62f), ) Spacer(modifier = Modifier.height(2.dp)) - Surface( - onClick = action.onClick, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(100.dp)), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.10f), - contentColor = Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), - modifier = Modifier.onFocusChanged { if (it.isFocused) onActionFocused() }, - ) { - Box( - modifier = Modifier - .width(140.dp) - .padding(vertical = 7.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = action.label, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 15.5.sp, - lineHeight = 18.5.sp, + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + actions.forEach { action -> + Surface( + onClick = action.onClick, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(100.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.White.copy(alpha = 0.10f), + contentColor = Color.White, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, ), - fontWeight = FontWeight.SemiBold, - ) + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + modifier = Modifier.onFocusChanged { if (it.isFocused) onActionFocused() }, + ) { + Box( + modifier = Modifier + .width(140.dp) + .padding(vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = action.label, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 15.5.sp, + lineHeight = 18.5.sp, + ), + fontWeight = FontWeight.SemiBold, + ) + } + } } } } @@ -1678,8 +1689,23 @@ private fun cardSubtitle(item: CalendarItem): String? { return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") } +/** + * The views an empty week links to: always the other two, never the one on + * screen (silo-server #1494, silo-apple #513). "everything" is the phone's + * spelling of All; any other legacy filter links to all three views. + */ +private fun emptyStateLinks(filter: String): List> { + val view = if (filter == CalendarFilter.Everything) CalendarFilter.All else filter + return listOf( + CalendarFilter.Following to "Following", + CalendarFilter.Trending to "Trending", + CalendarFilter.All to "All", + ).filter { (value, _) -> value != view } +} + private fun emptyTitle(filter: String): String = when (filter) { CalendarFilter.Following -> "Nothing from shows you follow" + CalendarFilter.Trending -> "Nothing trending this week" else -> "Nothing scheduled this week" } From 7c10775487569ba09cd293a984acadb9425111d9 Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 29 Sep 2026 20:55:44 +0200 Subject: [PATCH 04/10] fix(downloads): register episode downloads under their series (#407) * fix(downloads): register episode downloads under their series Single-episode downloads sent the episode id as content_id with no episode_id. Since the v2 migration the server requires the series id as content_id plus episode_id and answers anything else with 404, so the Download button on the series page and on an episode page, season downloads and monitored-series downloads all failed for episodes. - startEpisode sends the series id and episode_id. - The detail screen routes every episode download through startEpisode. - Local rows store the episode id as contentId (the series stays in seriesContentId), because offline playback, the Downloads list and watched-state lookups find a download by the item it plays. Series and season downloads already came back keyed by series and were stored that way too. * fix(downloads): name and route episode downloads from the series - The series title falls back to the series page, never the episode's own title, so the Downloads tab groups the episode under its series. - Unknown season or episode numbers leave the S/E label out instead of showing S0E0. - An episode row without a series id uses the series page it was opened from; on a standalone episode page it reports a failed start instead of sending a request the server rejects. * fix(downloads): group standalone episode downloads under their series An episode page already loads its parent series for the poster; keep the series title from that same load and use it, with the series poster, when the episode detail lacks a series title, instead of falling back to the literal "Series". * fix(downloads): load the series title before storing an episode download A download started from an episode page before its parent series had loaded could still store "Series" as the group title. When no title is known yet, fetch the parent detail (the cached prefetch the page uses) before starting the download. * fix(downloads): use the fetched series poster for episode downloads When the parent series is fetched because its title or poster is not known yet, use both, so an episode page that never loaded its parent (no season number) still groups the download under the series poster instead of the episode's own artwork. The parent is fetched at most once. * fix(downloads): skip the parent fetch on a series page A series page already is the parent, so a missing poster there must not trigger another request for the same series before every episode download. --- .../silo/common/downloads/DownloadEnqueuer.kt | 21 +++--- .../downloads/DownloadSidecarMapping.kt | 14 ++-- .../downloads/DownloadSidecarMappingTest.kt | 40 +++++++++++ .../ui/screens/detail/ItemDetailScreen.kt | 4 +- .../ui/screens/detail/ItemDetailViewModel.kt | 67 ++++++++++++++++--- 5 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMappingTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt index c24606aaf..521ebf3f2 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt @@ -91,8 +91,9 @@ class DownloadEnqueuer( /** * Single-episode download. Resolves the episode's catalog row for rich - * sidecar metadata (series title, S/E numbers, still image). The - * `seriesContentId` is what gets associated on the server side; the + * sidecar metadata (series title, S/E numbers, still image). The server + * registers the entry under `seriesContentId` with `episodeContentId` as + * its episode, and rejects an episode sent as the content id; the * `fileId` chosen here is the user-preferred quality. */ suspend fun startEpisode( @@ -100,8 +101,8 @@ class DownloadEnqueuer( episodeContentId: String, fileId: Int, seriesTitle: String, - seasonNumber: Int, - episodeNumber: Int, + seasonNumber: Int?, + episodeNumber: Int?, episodeTitle: String?, posterUrl: String? = null, downloadQualityOverride: DownloadQuality? = null, @@ -113,11 +114,15 @@ class DownloadEnqueuer( Log.i(TAG, "startEpisode: fileId=$fileId already queued/downloading — skipping duplicate") return alreadyActive() } - val displayTitle = "$seriesTitle S${seasonNumber}E${episodeNumber}" + - (episodeTitle?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: "") + // Unknown numbers leave the S/E label out rather than print "S0E0". + val episodeCode = if (seasonNumber != null && episodeNumber != null) "S${seasonNumber}E${episodeNumber}" else null + val shownEpisodeTitle = episodeTitle?.takeIf { it.isNotBlank() } + val displayTitle = listOfNotNull(seriesTitle, episodeCode).joinToString(" ") + + (shownEpisodeTitle?.let { " · $it" } ?: "") val record = when (val r = repository.create( downloadRequest( - contentId = episodeContentId, + contentId = seriesContentId, + episodeId = episodeContentId, fileId = fileId, downloadQualityOverride = downloadQualityOverride, ), @@ -132,7 +137,7 @@ class DownloadEnqueuer( val sidecar = DownloadSidecar( record = record, title = seriesTitle, - subtitle = "S${seasonNumber}E${episodeNumber}" + (episodeTitle?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: ""), + subtitle = listOfNotNull(episodeCode, shownEpisodeTitle).joinToString(" · ").ifEmpty { null }, posterUrl = posterUrl, seriesTitle = seriesTitle, seriesContentId = seriesContentId, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt index 2f52ea8bd..316e2beb1 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt @@ -10,9 +10,15 @@ import kotlinx.serialization.json.Json /** * Maps between the on-the-wire-ish [DownloadSidecar] (the download's full local * picture) and the Room [DownloadEntity] projection. Chapters serialize to a JSON - * column (no TypeConverter). `episodeId`/`batchId`/`deliveryFormat`/ - * `targetBitrateKbps` on [DownloadRecord] have no entity column and aren't read - * anywhere in the download/playback paths, so they round-trip as null. + * column (no TypeConverter). `batchId`/`deliveryFormat`/`targetBitrateKbps` on + * [DownloadRecord] have no entity column and aren't read anywhere in the + * download/playback paths, so they round-trip as null. + * + * The server keys an episode's entry by its series (`contentId`) plus + * `episodeId`, while every local reader (offline playback, the Downloads list, + * watched state) looks a download up by the item it plays. The entity's + * `contentId` is therefore the episode for episodes; the series stays in + * `seriesContentId`. */ private val mappingJson = Json { ignoreUnknownKeys = true } @@ -22,7 +28,7 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit profileId = profileId, mediaFileId = record.mediaFileId, recordId = record.id, - contentId = record.contentId, + contentId = record.episodeId?.takeIf { it.isNotBlank() } ?: record.contentId, title = title, subtitle = subtitle, posterUrl = posterUrl, diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMappingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMappingTest.kt new file mode 100644 index 000000000..b488cf4d9 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMappingTest.kt @@ -0,0 +1,40 @@ +package org.siloserver.silo.common.downloads + +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadSidecar +import kotlin.test.Test +import kotlin.test.assertEquals + +class DownloadSidecarMappingTest { + private fun sidecar(contentId: String, episodeId: String?) = DownloadSidecar( + record = DownloadRecord( + id = "dl-1", + contentId = contentId, + episodeId = episodeId, + mediaFileId = 7, + kind = "queued", + status = "ready", + createdAt = "2026-09-28T10:00:00Z", + ), + title = "Show", + seriesContentId = "series-1", + updatedAtMs = 0L, + ) + + @Test + fun `an episode is stored under the episode it plays, not its series`() { + // The server keys the entry by series + episode; offline playback and + // the Downloads list look it up by the episode. + val entity = sidecar(contentId = "series-1", episodeId = "episode-2").toEntity("server", "profile") + + assertEquals("episode-2", entity.contentId) + assertEquals("series-1", entity.seriesContentId) + } + + @Test + fun `a movie keeps its own content id`() { + val entity = sidecar(contentId = "movie-1", episodeId = null).toEntity("server", "profile") + + assertEquals("movie-1", entity.contentId) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index 62a71ee1a..a917f1a76 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -749,7 +749,7 @@ fun ItemDetailScreen( viewModel.onDownloadTapped( version, episode.title, forceRedownloadMissingLocal = episodeDownloadState.needsLocalRecovery, - downloadContentId = episode.contentId, + episode = episode, ) }, qualityAction = { quality -> @@ -757,7 +757,7 @@ fun ItemDetailScreen( version, episode.title, forceRedownloadMissingLocal = episodeDownloadState.needsLocalRecovery, downloadQuality = quality, - downloadContentId = episode.contentId, + episode = episode, ) }, estimate = org.siloserver.silo.model.download.DownloadSizeEstimate diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt index bcd9a7702..52df271da 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailViewModel.kt @@ -62,6 +62,8 @@ data class ItemDetailUiState( val isLoadingSelectedEpisodeDetail: Boolean = false, /** Parent-series portrait art used when an episode's own artwork is a wide still. */ val episodeSeriesPosterUrl: String? = null, + /** Parent series title on a standalone episode page, for download grouping. */ + val episodeSeriesTitle: String? = null, val episodeSeriesPosterThumbhash: String? = null, /** * Route-scoped episode lists keyed by season. Unlike the repository's @@ -235,7 +237,7 @@ class ItemDetailViewModel( displayTitle: String, forceRedownloadMissingLocal: Boolean = false, downloadQuality: DownloadQuality? = null, - downloadContentId: String = contentId, + episode: ItemDetail? = null, ) { val existing = downloadRecordFor(version) when ( @@ -257,11 +259,11 @@ class ItemDetailViewModel( DetailDownloadTapAction.ReplaceAndStart -> viewModelScope.launch { val staleRecord = existing if (staleRecord == null || downloadsRepository.delete(staleRecord.id) is ApiResult.Success) { - startDownload(version, displayTitle, downloadQuality, downloadContentId) + startDownload(version, displayTitle, downloadQuality, episode) } } DetailDownloadTapAction.Start -> viewModelScope.launch { - startDownload(version, displayTitle, downloadQuality, downloadContentId) + startDownload(version, displayTitle, downloadQuality, episode) } } } @@ -275,16 +277,58 @@ class ItemDetailViewModel( version: FileVersion, displayTitle: String, downloadQuality: DownloadQuality?, - downloadContentId: String, + episode: ItemDetail?, ) { + // The series page names its selected episode; an episode page is + // itself the episode. Episodes register under their series, which the + // server requires, so they go through startEpisode. + val pageDetail = _uiState.value.detail + val item = episode ?: pageDetail + val seriesPage = pageDetail?.takeIf { it.type == "series" } + // On the series page the page itself is the parent when the episode + // row omits its series id. + val seriesId = item?.seriesId?.takeIf { it.isNotBlank() } + ?: seriesPage?.takeIf { episode != null }?.contentId // wifiOnly read from per-profile PlayerSettingsStore inside - // DownloadEnqueuer.start; default true. - val result = downloadEnqueuer.start( - contentId = downloadContentId, - fileId = version.fileId, - displayTitle = displayTitle, - downloadQualityOverride = downloadQuality, - ) + // DownloadEnqueuer; default true. + val result = when { + item?.type == "episode" && seriesId != null -> { + // Never the episode's own title or art: the Downloads tab + // groups episodes under the series' name and poster. + val knownTitle = item.seriesTitle?.takeIf { it.isNotBlank() } + ?: seriesPage?.title + ?: _uiState.value.episodeSeriesTitle?.takeIf { it.isNotBlank() } + val knownPoster = seriesPage?.posterUrl ?: _uiState.value.episodeSeriesPosterUrl + // On an episode page the parent load may not have finished (or + // may not run at all); the title and poster are stored with the + // download, so fetch the parent now when either is missing. A + // series page already is the parent, so it never refetches. + val parent = if (seriesPage == null && (knownTitle == null || knownPoster == null)) { + (catalogRepository.getItemDetailForPrefetch(seriesId, libraryId = libraryId) as? ApiResult.Success)?.data + } else { + null + } + downloadEnqueuer.startEpisode( + seriesContentId = seriesId, + episodeContentId = item.contentId, + fileId = version.fileId, + seriesTitle = knownTitle ?: parent?.title?.takeIf { it.isNotBlank() } ?: "Series", + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.title, + posterUrl = knownPoster ?: parent?.posterUrl ?: pageDetail?.posterUrl, + downloadQualityOverride = downloadQuality, + ) + } + // The server rejects an episode sent without its series. + item?.type == "episode" -> ApiResult.Error(0, "missing_series", "This episode isn't linked to a series.") + else -> downloadEnqueuer.start( + contentId = item?.contentId ?: contentId, + fileId = version.fileId, + displayTitle = displayTitle, + downloadQualityOverride = downloadQuality, + ) + } _downloadStartEvents.emit(result is ApiResult.Success) } @@ -625,6 +669,7 @@ class ItemDetailViewModel( it.copy( episodeSeriesPosterUrl = result.data.posterUrl, episodeSeriesPosterThumbhash = result.data.posterThumbhash, + episodeSeriesTitle = result.data.title, ) } } From ad6f3715acbf4b9ee28b319ceb02a07ccd42de16 Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 29 Sep 2026 20:55:54 +0200 Subject: [PATCH 05/10] fix(detail): resolve Auto for a series page episode like playback (#408) * fix(detail): resolve Auto for a series page episode like playback On the series page, Auto showed and downloaded the selected episode's first version in server order, while Play on Auto resolved the last-played file, then the quality preference. A user who watches the version with subtitles could see and download a different one. Episodes now use the same resolution as the movie page and the player, moved into one shared detailDisplayVersionIndex helper, so the version Auto shows is the one Play streams and Download saves. * fix(detail): keep the display index inside the version list An explicit pick returned before the empty-list check, so a series episode still loading its versions got an index into an empty list. Check for an empty list first and clamp an explicit pick to the list. * fix(detail): show no version for a stale explicit pick Clamping an out-of-range pick named the last file for Download while Play, which sends no file id for a stale pick, resolved another. A stale pick now names no version, as main did. --- .../ui/screens/detail/ItemDetailScreen.kt | 58 ++++++-------- .../ui/screens/detail/MediaSelectors.kt | 33 ++++++++ .../detail/DetailDisplayVersionTest.kt | 79 +++++++++++++++++++ 3 files changed, 134 insertions(+), 36 deletions(-) create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailDisplayVersionTest.kt diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt index a917f1a76..291acf1a3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/ItemDetailScreen.kt @@ -83,7 +83,6 @@ import org.siloserver.silo.model.download.labelFor import org.siloserver.silo.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.siloserver.silo.common.settings.PlayerSettingsStore import org.siloserver.silo.network.ServerRegistry -import org.siloserver.silo.playback.selectPlaybackVersion import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.siloserver.silo.metadata.DescriptionTranslationPhase @@ -622,8 +621,18 @@ fun ItemDetailScreen( it.contentId == state.selectedEpisodeContentId } val selectedEpisodeDetail = state.selectedEpisodeDetail - val selectedEpisodeVersionIndex = state.selectedVersionIndex - .coerceIn(0, (selectedEpisodeDetail?.versions?.lastIndex ?: 0).coerceAtLeast(0)) + // Auto resolves like the movie page and playback, so + // the version shown is the one Play and Download use. + val selectedEpisodeVersions = selectedEpisodeDetail?.versions.orEmpty() + val selectedEpisodeVersionIndex = detailDisplayVersionIndex( + versions = selectedEpisodeVersions, + explicitIndex = state.selectedVersionIndex + .coerceIn(0, selectedEpisodeVersions.lastIndex.coerceAtLeast(0)) + .takeIf { state.hasExplicitVersionSelection }, + lastFileId = selectedEpisodeDetail?.userData?.lastFileId, + preferredQuality = preferredQuality, + fallbackIndex = 0, + ) val selectedEpisodeFileId = selectedEpisodeDetail?.versions ?.getOrNull(selectedEpisodeVersionIndex) ?.fileId @@ -822,41 +831,18 @@ fun ItemDetailScreen( // flow through to the DownloadButton. val downloadRecords by viewModel.downloads.collectAsState() // Auto preview must resolve through the SAME shared - // selector as playback (lastFileId → preferred-quality - // rank → bestAvailable), not just lastFileId-else-[0] — + // selector as playback, not just lastFileId-else-[0] — // otherwise the previewed version (and the audio/subtitle // lists derived from it) can describe a file playback - // won't use. An explicit user pick still wins. TV parity: - // selectTvDetailDisplayVersion does the same. - val videoDisplayVersionIndex = if ( - state.hasExplicitVersionSelection || detail.versions.isEmpty() - ) { - effectiveSelectedVersionIndex - } else if (preferredQuality == null) { - // The quality pref hasn't emitted from DataStore yet - // (a frame or two): don't auto-resolve against a - // missing pref — it would name a version the arriving - // pref immediately contradicts (first-frame flash). - // lastFileId is pref-independent and always wins in - // selectPlaybackVersion, so it can be shown at once; - // otherwise hold the bare "Auto" placeholder (-1 → - // no resolved version) until the pref lands. - detail.userData?.lastFileId - ?.let { lastFileId -> - detail.versions.indexOfFirst { it.fileId == lastFileId } - .takeIf { it >= 0 } - } - ?: -1 - } else { - val resolvedFileId = selectPlaybackVersion( - detail.versions, - detail.userData?.lastFileId, - preferredQuality, - ).fileId - detail.versions.indexOfFirst { it.fileId == resolvedFileId } - .takeIf { it >= 0 } - ?: effectiveSelectedVersionIndex - } + // won't use. TV parity: selectTvDetailDisplayVersion + // does the same. + val videoDisplayVersionIndex = detailDisplayVersionIndex( + versions = detail.versions, + explicitIndex = effectiveSelectedVersionIndex.takeIf { state.hasExplicitVersionSelection }, + lastFileId = detail.userData?.lastFileId, + preferredQuality = preferredQuality, + fallbackIndex = effectiveSelectedVersionIndex, + ) val selectedVersion = detail.versions.getOrNull(videoDisplayVersionIndex) val selectedLocalDownload = selectedVersion?.let { version -> localDownloadFor(version.fileId) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MediaSelectors.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MediaSelectors.kt index cb3a5ec6a..37704f59a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MediaSelectors.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/detail/MediaSelectors.kt @@ -61,6 +61,7 @@ import org.siloserver.silo.model.catalog.hasEditionChoices import org.siloserver.silo.model.catalog.FileVersion import org.siloserver.silo.model.catalog.SubtitleTrack import org.siloserver.silo.player.DolbyVisionDetection +import org.siloserver.silo.playback.selectPlaybackVersion /** * One iOS-style row inside [PlaybackSelectorCard]. Icon + group label lead, @@ -520,6 +521,38 @@ private fun BadgePill(text: String) { ) } +// ── Display version ─────────────────────────────────────────── + +/** + * Index of the version a video detail page shows for [versions], which is + * also the version its Download button saves. An explicit pick wins. Auto + * resolves through the same shared selector as playback (lastFileId → + * preferred-quality rank → bestAvailable), so what Auto previews is the file + * playback and downloads use. While the quality pref is still loading, only + * the pref-independent lastFileId is shown, otherwise -1 (the bare "Auto" + * placeholder), so the page never names a version the arriving pref + * contradicts. [fallbackIndex] covers an empty list or an unmatched version. + */ +internal fun detailDisplayVersionIndex( + versions: List, + explicitIndex: Int?, + lastFileId: Int?, + preferredQuality: String?, + fallbackIndex: Int, +): Int { + if (versions.isEmpty()) return fallbackIndex + // A stale pick (the list shrank after a refresh) names no version rather + // than a different one, since playback would not use a clamped file. + if (explicitIndex != null) return explicitIndex.takeIf { it in versions.indices } ?: -1 + if (preferredQuality == null) { + return lastFileId + ?.let { id -> versions.indexOfFirst { it.fileId == id }.takeIf { it >= 0 } } + ?: -1 + } + val resolvedFileId = selectPlaybackVersion(versions, lastFileId, preferredQuality).fileId + return versions.indexOfFirst { it.fileId == resolvedFileId }.takeIf { it >= 0 } ?: fallbackIndex +} + // ── Label formatting ────────────────────────────────────────── /** diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailDisplayVersionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailDisplayVersionTest.kt new file mode 100644 index 000000000..4272f9f70 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/detail/DetailDisplayVersionTest.kt @@ -0,0 +1,79 @@ +package org.siloserver.silo.android.ui.screens.detail + +import org.siloserver.silo.model.catalog.FileVersion +import kotlin.test.Test +import kotlin.test.assertEquals + +class DetailDisplayVersionTest { + // The 1080p file is listed first; the 720p file is the one with subtitles + // the profile last played. + private val versions = listOf( + FileVersion(fileId = 1080, resolution = "1080p"), + FileVersion(fileId = 720, resolution = "720p"), + ) + + @Test + fun `auto shows and downloads the last played version, not the first listed`() { + val index = detailDisplayVersionIndex( + versions = versions, + explicitIndex = null, + lastFileId = 720, + preferredQuality = "auto", + fallbackIndex = 0, + ) + + assertEquals(720, versions[index].fileId) + } + + @Test + fun `an explicit pick wins over the last played version`() { + val index = detailDisplayVersionIndex( + versions = versions, + explicitIndex = 0, + lastFileId = 720, + preferredQuality = "auto", + fallbackIndex = 0, + ) + + assertEquals(1080, versions[index].fileId) + } + + @Test + fun `auto names no version until the quality preference loads`() { + val index = detailDisplayVersionIndex( + versions = versions, + explicitIndex = null, + lastFileId = null, + preferredQuality = null, + fallbackIndex = 0, + ) + + assertEquals(-1, index) + } + + @Test + fun `an explicit pick with no versions loaded yields the fallback`() { + val index = detailDisplayVersionIndex( + versions = emptyList(), + explicitIndex = 1, + lastFileId = null, + preferredQuality = "auto", + fallbackIndex = 0, + ) + + assertEquals(0, index) + } + + @Test + fun `a stale explicit pick names no version instead of another one`() { + val index = detailDisplayVersionIndex( + versions = versions, + explicitIndex = 5, + lastFileId = 720, + preferredQuality = "auto", + fallbackIndex = 0, + ) + + assertEquals(-1, index) + } +} From 4721e4bf5fcf83021d3ebf0f74a7973981e7bb98 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:56:32 -0400 Subject: [PATCH 06/10] fix(requests): show readable request reasons instead of raw codes (#412) * fix(requests): show readable request reasons instead of raw codes The phone request detail printed request.reason as it came from the server, so a title with an open request showed "already_requested" under its status button. The TV request detail and the TV Requests notice for a title that cannot be requested printed the other codes the same way. Map the server's reason codes to sentences in the shared request presentation layer and use it on phone and TV. already_requested maps to nothing, because the request status already says where the request stands, and a code the client does not know also shows nothing instead of the raw value. The wording follows the web's labels: "This title is already in your library.", "Requests are disabled on this server.", "Your account is blocked from making requests." and "You've reached your request limit.". Refs #410 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep readable reason text that is not a code A reason that is not one of the server's codes, such as readable text from another server version, was dropped along with unknown codes. Only an unknown value that looks like a code (lowercase letters, digits and underscores) is hidden now; other text is shown as the server wrote it. Refs #410 Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../screens/requests/RequestDetailScreen.kt | 9 +++-- .../screens/requests/TvRequestDetailScreen.kt | 6 ++-- .../ui/screens/requests/TvRequestsScreen.kt | 4 +-- .../silo/model/request/RequestModels.kt | 12 +++++++ .../silo/model/request/RequestPresentation.kt | 19 +++++++++++ .../model/request/RequestPresentationTest.kt | 34 +++++++++++++++++++ 6 files changed, 77 insertions(+), 7 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt index d845001ca..495d2e61e 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/requests/RequestDetailScreen.kt @@ -42,6 +42,7 @@ import org.siloserver.silo.common.ui.components.DeferImagePresentationWhileScrol import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.request.RequestMediaDetail import org.siloserver.silo.model.request.RequestMediaResult +import org.siloserver.silo.model.request.reasonMessage import org.siloserver.silo.model.request.requestBackdropUrl import org.siloserver.silo.model.request.requestDisplayLabel import org.siloserver.silo.model.request.requestPosterUrl @@ -321,9 +322,11 @@ private fun RequestActions( @Composable private fun RequestDetailStatus(detail: RequestMediaDetail) { - val status = detail.request.reason - .takeIf { it.isNotBlank() } - ?: detail.availability.requestDisplayLabel() + val status = if (detail.request.reason.isBlank()) { + detail.availability.requestDisplayLabel() + } else { + detail.request.reasonMessage() + } ?: return Text( text = status, style = MaterialTheme.typography.bodySmall, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt index fd7902f67..3f8c41728 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt @@ -35,6 +35,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.request.RequestMediaDetail +import org.siloserver.silo.model.request.reasonMessage import org.siloserver.silo.model.request.requestBackdropUrl import org.siloserver.silo.model.request.requestPosterUrl import org.siloserver.silo.tv.ui.components.TvErrorScreen @@ -233,6 +234,7 @@ private fun RequestDetailContent( } val request = detail.request + val reasonMessage = request.reasonMessage() when { request.requestable -> { TvRequestActionPill( @@ -251,9 +253,9 @@ private fun RequestDetailContent( modifier = Modifier.padding(top = 12.dp), ) } - request.reason.isNotBlank() -> { + reasonMessage != null -> { Text( - text = request.reason, + text = reasonMessage, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp), diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsScreen.kt index e01c03884..c1e63aa6f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestsScreen.kt @@ -57,6 +57,7 @@ import org.siloserver.silo.model.request.RequestAvailability import org.siloserver.silo.model.request.RequestDiscoverySection import org.siloserver.silo.model.request.RequestMediaResult import org.siloserver.silo.model.request.RequestMediaType +import org.siloserver.silo.model.request.reasonMessage import org.siloserver.silo.tv.ui.components.TvErrorScreen import org.siloserver.silo.tv.ui.components.TvFilterChip import org.siloserver.silo.tv.ui.components.TvLoadingScreen @@ -620,8 +621,7 @@ private fun RequestMediaResult.requestKey(): String = "$mediaType-$tmdbId" private fun RequestMediaResult.nonActionableMessage(): String = when { availability == RequestAvailability.Available -> "This title is already in your library." request.status?.isNotBlank() == true -> "$title is already ${request.status}." - request.reason.isNotBlank() -> request.reason - else -> "This title cannot be requested right now." + else -> request.reasonMessage() ?: "This title cannot be requested right now." } private fun searchEmptyMessage( diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestModels.kt index 8eee794e0..36613abbc 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestModels.kt @@ -27,6 +27,18 @@ object RequestOutcome { const val Failed = "failed" } +/** + * Values of [RequestState.reason]: why a title cannot be requested. They are + * codes for deciding what to offer, not text; show [reasonMessage] instead. + */ +object RequestReason { + const val AlreadyRequested = "already_requested" + const val AlreadyAvailable = "already_available" + const val RequestsDisabled = "requests_disabled" + const val Blocked = "blocked" + const val QuotaExceeded = "quota_exceeded" +} + object RequestAvailability { const val Missing = "missing" const val Available = "available" diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestPresentation.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestPresentation.kt index fb6013e0f..d708a346c 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestPresentation.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/request/RequestPresentation.kt @@ -48,6 +48,25 @@ fun String.requestDisplayLabel(): String = when (lowercase()) { .ifBlank { this } } +/** + * A sentence saying why this title cannot be requested, or null when there is + * nothing worth showing. A title that already has a request gets null: its + * request status already says where it stands. A code this client does not + * know also gets null, so a raw code never reaches the screen, while reason + * text that is not a code is shown as the server wrote it. + */ +fun RequestState.reasonMessage(): String? = when (reason) { + RequestReason.AlreadyRequested -> null + RequestReason.AlreadyAvailable -> "This title is already in your library." + RequestReason.RequestsDisabled -> "Requests are disabled on this server." + RequestReason.Blocked -> "Your account is blocked from making requests." + RequestReason.QuotaExceeded -> "You've reached your request limit." + else -> reason.takeUnless { it.isBlank() || ReasonCodePattern.matches(it) } +} + +/** What a server reason code looks like, as opposed to readable text. */ +private val ReasonCodePattern = Regex("[a-z0-9_]+") + fun MediaRequest.canCancel(): Boolean = outcome == RequestOutcome.Active && status == RequestStatus.Pending diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestPresentationTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestPresentationTest.kt index 86c4d0bdd..25b0327bd 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestPresentationTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/request/RequestPresentationTest.kt @@ -48,6 +48,40 @@ class RequestPresentationTest { assertEquals("Partially Available", "partially_available".requestDisplayLabel()) } + @Test + fun `reason message reads each server reason as a sentence`() { + assertEquals( + "This title is already in your library.", + RequestState(reason = RequestReason.AlreadyAvailable).reasonMessage(), + ) + assertEquals( + "Requests are disabled on this server.", + RequestState(reason = RequestReason.RequestsDisabled).reasonMessage(), + ) + assertEquals( + "Your account is blocked from making requests.", + RequestState(reason = RequestReason.Blocked).reasonMessage(), + ) + assertEquals( + "You've reached your request limit.", + RequestState(reason = RequestReason.QuotaExceeded).reasonMessage(), + ) + } + + @Test + fun `reason message is empty for an existing request, an unknown code, or no reason`() { + assertNull( + RequestState(status = RequestStatus.Downloading, reason = RequestReason.AlreadyRequested).reasonMessage(), + ) + assertNull(RequestState(reason = "some_future_reason").reasonMessage()) + assertNull(RequestState(requestable = true).reasonMessage()) + } + + @Test + fun `reason message keeps readable reason text from the server`() { + assertEquals("Already requested", RequestState(reason = "Already requested").reasonMessage()) + } + @Test fun `can cancel only while active and pending`() { assertTrue(request(status = RequestStatus.Pending, outcome = RequestOutcome.Active).canCancel()) From 1ac6f4a83cff50b36a44915380234fadc6f4caf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:21:12 -0400 Subject: [PATCH 07/10] fix(ci): align Ruby dependencies and verify release bundles (#394) Update rubyzip to 3.4.0 and Fastlane to 2.240.1. Align the release runtime, Gemfile, lockfile, and Ruby setup action with Ruby 3.4.11. Check frozen dependency installation, Fastlane/Supply loading, and both phone and TV release bundles in CI. Validation: release bundles, unit tests, lint, and Kody review passed on 046d3694. AI disclosure: gpt-6.1-sol in T3 Code using the Codex provider; tooling: GitHub CLI, Ruby, Bundler, and Gradle through GitHub Actions. --- .github/workflows/android-build.yml | 51 ++++++++++- .github/workflows/release.yml | 2 +- .ruby-version | 2 +- Gemfile | 2 +- Gemfile.lock | 132 ++++++++++++++++------------ 5 files changed, 129 insertions(+), 60 deletions(-) diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index a34ce91c0..56a6fdb6e 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -1,7 +1,7 @@ name: Android Builds -# CI gate: unit tests on main pushes and pull requests. Release artifacts -# (Play Store bundles, sideload APKs, GitHub releases) are owned by +# CI gate: tests, lint, and release readiness on main pushes and pull requests. +# Release artifacts (Play Store bundles, sideload APKs, GitHub releases) are owned by # release.yml — triggered by v* tags or its workflow_dispatch. on: @@ -93,6 +93,53 @@ jobs: path: android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar if-no-files-found: error + release-readiness: + name: Release readiness + if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + # Match release.yml: the pinned Ruby and frozen lockfile must work + # before a release reaches the Google Play job. + - name: Set up Ruby and install release dependencies + uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + with: + bundler-cache: true + + - name: Load the release lane and Play client + run: | + bundle exec fastlane lanes + bundle exec ruby -e "require 'supply'" + env: + FASTLANE_SKIP_UPDATE_CHECK: "true" + FASTLANE_OPT_OUT_USAGE: "true" + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + # Exercise both Play artifacts, including R8 and bundle packaging. + # Signing and publishing remain in release.yml. + - name: Build phone and TV release bundles + run: | + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + :androidApp:bundleRelease :androidTvApp:bundleRelease \ + --max-workers=2 + test -s androidApp/build/outputs/bundle/release/androidApp-release.aab + test -s androidTvApp/build/outputs/bundle/release/androidTvApp-release.aab + unit-tests: name: Unit tests if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7256269ee..4e894d037 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -250,7 +250,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 with: bundler-cache: true diff --git a/.ruby-version b/.ruby-version index e650c01d9..0ce07b72f 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.9 +3.4.11 diff --git a/Gemfile b/Gemfile index 9f3e0ef5f..d3afcfcbf 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "~> 3.2" +ruby "~> 3.4.0" gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock index 1ca8bb94c..c4ddb77c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,6 +30,7 @@ GEM base64 (0.3.0) benchmark (0.5.0) bigdecimal (4.1.2) + cgi (0.5.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -42,38 +43,26 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) + erb (6.0.7) excon (1.5.0) logger - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + faraday (2.14.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.237.0) + fastlane (2.240.1) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.9.0, < 3.0.0) @@ -83,24 +72,27 @@ GEM base64 (~> 0.2) benchmark (>= 0.1.0) bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 2.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) + google-apis-androidpublisher_v3 (~> 0.99) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-env (>= 1.6.0, < 2.4.0) google-cloud-storage (~> 1.31) highline (~> 2.0) - http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) @@ -113,11 +105,11 @@ GEM optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -172,6 +164,12 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) json (2.21.2) jwt (3.2.0) @@ -184,14 +182,31 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) pstore (0.2.1) public_suffix (7.0.5) rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -199,9 +214,8 @@ GEM retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) - security (0.1.5) + rubyzip (3.4.0) + security (0.3.0) signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -210,15 +224,17 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.6.0) + uri (1.1.1) word_wrap (1.0.0) xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) @@ -257,6 +273,8 @@ CHECKSUMS base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 + cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a @@ -267,22 +285,16 @@ CHECKSUMS domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50 - faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + faraday (2.14.4) sha256=9bb4408c44621b0dbaaaa39e5dbfc4dbe58cc6751b6eb94d16ae9e1f2f2fab6e faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb - faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 - faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 - faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 - faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 - faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 - faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 - faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 - faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 - faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa - faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 - fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9 + fastlane (2.240.1) sha256=cf792bc99ec6af6574ea416cda6530f519c8b4b3a46b1a3b52488ebf1696aa5c fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff @@ -299,6 +311,8 @@ CHECKSUMS highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 @@ -310,38 +324,46 @@ CHECKSUMS mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 - ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 - security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + rubyzip (3.4.0) sha256=6de39bc9eba302b635a476d16c9e16b0872ad24517c2f98f2b3a7ea23caff57b + security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0 signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea - terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 RUBY VERSION - ruby 3.2.9 + ruby 3.4.11 BUNDLED WITH 4.0.15 From 037d5979edea2d2ff63119847203797f56d251c2 Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 29 Sep 2026 21:48:18 +0200 Subject: [PATCH 08/10] fix(downloads): report completion and show finished downloads as ready (#418) Finished Android downloads now become playable in the Downloads tab and send revision-bound status events to the server. Persist revisions in Room v12 and retain queued reports across network loss, PIN verification, and credential refresh. Resolve revisions for older queued jobs under their saved owner, cancel pending reports during transfer cancellation, and compare acknowledgement timestamps chronologically. Validation: full unit tests, debug/release lint, phone and TV debug APK builds, and focused download regression tests passed. CI supply-chain checks, debug/release lint, full debug unit tests, Ruby release dependencies, and both phone and TV release bundles pass on 71a4f5a8. All review threads are resolved. Validation tasks: changes #329 C1 (Android phone and tablet). AI disclosure: AI-assisted with claude-opus-5-5[1m] through Claude Code in T3 Code; gpt-6-astra through OpenAI Codex CLI 0.155.1; and gpt-6.1-sol through the Codex harness in T3 Code. Review tooling included Codex CLI codex review, CodeRabbit, and Kody. --- .../12.json | 984 ++++++++++++++++++ .../silo/common/data/db/SiloDatabase.kt | 3 +- .../common/data/db/entity/DownloadEntity.kt | 3 + .../silo/common/downloads/DownloadEnqueuer.kt | 1 + .../downloads/DownloadSidecarMapping.kt | 2 + .../common/downloads/DownloadStatusWorker.kt | 169 +++ .../silo/common/downloads/DownloadWorker.kt | 124 ++- .../data/db/SiloDatabaseMigrationTest.kt | 20 + .../DownloadStatusReportOutcomeTest.kt | 49 + .../silo/android/di/AndroidModule.kt | 9 + .../android/downloads/AppWorkerFactory.kt | 11 + .../screens/downloads/DownloadsViewModel.kt | 21 +- .../silo/model/download/DownloadModels.kt | 11 + .../silo/network/api/DownloadsApi.kt | 4 + .../network/apiv2/DownloadRegistryV2Api.kt | 31 +- .../silo/repository/DownloadsRepository.kt | 99 +- .../network/apiv2/DownloadRegistryV2Test.kt | 86 ++ .../DownloadsRepositoryCompletionTest.kt | 287 +++++ 18 files changed, 1886 insertions(+), 28 deletions(-) create mode 100644 android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/12.json create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStatusWorker.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadStatusReportOutcomeTest.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/repository/DownloadsRepositoryCompletionTest.kt diff --git a/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/12.json b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/12.json new file mode 100644 index 000000000..ca15542f8 --- /dev/null +++ b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/12.json @@ -0,0 +1,984 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "8a348d04acd6cd7fe41b6ee065fb65cb", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8a348d04acd6cd7fe41b6ee065fb65cb')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt index fc61993f6..f690dc1d8 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt @@ -56,7 +56,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity MembershipProjectionEntity::class, LegacyMembershipQuarantineEntity::class, ], - version = 11, + version = 12, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -68,6 +68,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity AutoMigration(from = 7, to = 8), AutoMigration(from = 8, to = 9), AutoMigration(from = 10, to = 11), + AutoMigration(from = 11, to = 12), ], ) abstract class SiloDatabase : RoomDatabase() { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt index 1da0341a1..4ce7c95f4 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt @@ -79,4 +79,7 @@ data class DownloadEntity( /** Quality the server actually delivered after any compatibility fallback; * may differ from [quality]. Same offline-fallback reason as above. */ val effectiveQuality: String? = null, + /** Registry revision whose bytes this row describes. A local completion + * only speaks for this revision; null on rows written before v12. */ + val revision: Int? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt index 521ebf3f2..52a2a570d 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadEnqueuer.kt @@ -444,6 +444,7 @@ class DownloadEnqueuer( deviceId = deviceId, loginId = authority?.loginId, origin = authority?.scope?.serverUrl, + revision = record.revision, ) } if (authority == null) persistAndEnqueue() diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt index 316e2beb1..77478cbf6 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt @@ -57,6 +57,7 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit updatedAtMs = updatedAtMs, quality = record.quality, effectiveQuality = record.effectiveQuality, + revision = record.revision, ) fun DownloadEntity.toSidecar(): DownloadSidecar = @@ -75,6 +76,7 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = completedAt = completedAt, quality = quality, effectiveQuality = effectiveQuality, + revision = revision, ), title = title, subtitle = subtitle, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStatusWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStatusWorker.kt new file mode 100644 index 000000000..b87c601c7 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStatusWorker.kt @@ -0,0 +1,169 @@ +package org.siloserver.silo.common.downloads + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadStatusEvent +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.DeviceMetadataProvider +import org.siloserver.silo.network.DurableLoginAuthorityProvider +import org.siloserver.silo.repository.DownloadsRepository +import java.util.concurrent.TimeUnit + +/** + * Tells the server's managed registry what this device holds for one download + * (`PATCH /api/v2/downloads/{id}`: `downloading` when bytes start, `completed` + * once the file is published). The file route never marks completion itself, + * so without this report the server row stays `ready`. + * + * Runs as its own network-constrained unique job per download, separate from + * the transfer, so an event recorded offline or cut short by process death is + * sent once the device is back online. A newer event replaces a pending older + * one; a retry resends the same event, which the server acknowledges without + * changing the entry. + */ +class DownloadStatusWorker( + appContext: Context, + params: WorkerParameters, + private val repository: DownloadsRepository, + private val authorities: DurableLoginAuthorityProvider, + private val devices: DeviceMetadataProvider, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val downloadId = inputData.getString(KEY_DOWNLOAD_ID) ?: return Result.failure() + val event = DownloadStatusEvent( + status = inputData.getString(KEY_STATUS) ?: return Result.failure(), + updatedAt = inputData.getString(KEY_UPDATED_AT) ?: return Result.failure(), + revision = inputData.getInt(KEY_REVISION, 0), + ) + val serverId = inputData.getString(KEY_SERVER_ID) ?: return Result.failure() + val profileId = inputData.getString(KEY_PROFILE_ID) ?: return Result.failure() + if (runAttemptCount >= MAX_ATTEMPTS) { + Log.w(TAG, "status report gave up id=$downloadId status=${event.status} after $runAttemptCount attempts") + return Result.failure() + } + // Only the owner the download ran for can report it. Another login or + // profile may be active for now; wait for it to come back. + val authority = authorities.snapshotDurableLoginAuthority() ?: return Result.retry() + if (!downloadWorkMatchesOwner(inputData.getString(KEY_LOGIN_ID), inputData.getString(KEY_ORIGIN), + inputData.getString(KEY_DEVICE_ID), serverId, profileId, authority, devices.current()?.id)) return Result.retry() + + val result = repository.reportStatus(downloadId, event, authority) + return when (downloadStatusReportOutcome(result)) { + DownloadStatusReportOutcome.Settled -> { + Log.i(TAG, "status report settled id=$downloadId status=${event.status} answer=${result.describe()}") + Result.success() + } + DownloadStatusReportOutcome.Reconcile -> { + // The entry's revision moved on, so this event describes replaced + // bytes. Drop it and read the registry instead. + Log.i(TAG, "status report superseded id=$downloadId revision=${event.revision}") + repository.refresh() + Result.success() + } + DownloadStatusReportOutcome.RetryLater -> { + Log.i(TAG, "status report retry id=$downloadId status=${event.status} answer=${result.describe()}") + Result.retry() + } + } + } + + private fun ApiResult<*>.describe(): String = when (this) { + is ApiResult.Success -> "ok" + is ApiResult.Error -> "$code ${error.ifBlank { message }}" + is ApiResult.NetworkError -> "network" + } + + companion object { + private const val TAG = "DownloadStatusWorker" + const val KEY_DOWNLOAD_ID = "download_id" + const val KEY_STATUS = "status" + const val KEY_UPDATED_AT = "updated_at" + const val KEY_REVISION = "revision" + const val KEY_SERVER_ID = "server_id" + const val KEY_PROFILE_ID = "profile_id" + const val KEY_DEVICE_ID = "device_id" + const val KEY_LOGIN_ID = "login_id" + const val KEY_ORIGIN = "origin" + + /** Exponential from [BACKOFF_SECONDS] (WorkManager caps each wait at + * 5 hours), so this spans a few days before the report is dropped. */ + private const val MAX_ATTEMPTS = 20 + private const val BACKOFF_SECONDS = 30L + + fun uniqueName(downloadId: String): String = "download_status_$downloadId" + + /** Queue [event] for [downloadId], replacing a report still waiting to go out. */ + fun enqueue( + context: Context, + downloadId: String, + event: DownloadStatusEvent, + serverId: String, + profileId: String, + deviceId: String?, + loginId: String?, + origin: String?, + ) { + val request = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + KEY_DOWNLOAD_ID to downloadId, + KEY_STATUS to event.status, + KEY_UPDATED_AT to event.updatedAt, + KEY_REVISION to event.revision, + KEY_SERVER_ID to serverId, + KEY_PROFILE_ID to profileId, + KEY_DEVICE_ID to deviceId, + KEY_LOGIN_ID to loginId, + KEY_ORIGIN to origin, + ), + ) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() + Log.i(TAG, "enqueue id=$downloadId status=${event.status} revision=${event.revision}") + WorkManager.getInstance(context) + .enqueueUniqueWork(uniqueName(downloadId), ExistingWorkPolicy.REPLACE, request) + } + + /** Drop a report still waiting to go out: the transfer it describes was + * cancelled or failed, so the server row must not move to `downloading`. */ + fun cancel(context: Context, downloadId: String) { + WorkManager.getInstance(context).cancelUniqueWork(uniqueName(downloadId)) + } + } +} + +internal enum class DownloadStatusReportOutcome { Settled, Reconcile, RetryLater } + +/** + * What the server's answer means for a queued status event. + * + * - `409`: the revision changed; the event describes replaced bytes. + * - `400`/`422`: most often the event time is ahead of the server's clock + * (future times are rejected), which a later retry of the same event fixes. + * - `404`/`403` and other client errors: the entry is gone, not reportable + * (preparing, failed, revoked), or downloads are off; resending cannot help. + * - Profile PIN verification, auth, throttling, server errors, network loss and identity changes are transient. + */ +internal fun downloadStatusReportOutcome(result: ApiResult): DownloadStatusReportOutcome = when (result) { + is ApiResult.Success -> DownloadStatusReportOutcome.Settled + is ApiResult.NetworkError -> DownloadStatusReportOutcome.RetryLater + is ApiResult.Error -> when (result.code) { + 409 -> DownloadStatusReportOutcome.Reconcile + 403 -> if (result.error == "profile_verification_required") DownloadStatusReportOutcome.RetryLater else DownloadStatusReportOutcome.Settled + 0, 400, 401, 408, 422, 429 -> DownloadStatusReportOutcome.RetryLater + in 500..599 -> DownloadStatusReportOutcome.RetryLater + else -> DownloadStatusReportOutcome.Settled + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt index e83f3309d..c909952cd 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt @@ -21,6 +21,8 @@ import androidx.work.WorkManager import androidx.work.workDataOf import org.siloserver.silo.model.download.DownloadStatus import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadStatusEvent +import org.siloserver.silo.model.download.statusEnum import org.siloserver.silo.repository.DownloadsRepository import org.siloserver.silo.network.* import org.siloserver.silo.network.apiv2.managedDownloadAuth @@ -43,6 +45,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import java.io.IOException import java.net.URLDecoder +import java.time.Instant import java.util.concurrent.TimeUnit internal fun DownloadRecord.withWorkerStatus( @@ -141,20 +144,51 @@ class DownloadWorker( }.onFailure { Log.w(TAG, "setForeground initial failed", it) } var activeUri: String? = null - - // Resume state (survives process death + WorkManager retries): the partial's - // uri + the validator captured at download start. Resume offset is the REAL - // on-disk size (fd stat), never the metadata SIZE column (stale while pending). - val existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() - val resumeUri = existing?.localUri - val resumeFrom = resumeUri?.let { storage.partialSize(it) } ?: 0L - val resumeValidator = existing?.resumeValidator - // Resume ONLY with a validator: an unvalidated Range append would silently - // corrupt the file if the source changed (the server can't tell us). No - // validator → behave as a fresh download (no Range header). - val canResume = resumeFrom > 0 && resumeUri != null && !resumeValidator.isNullOrBlank() - try { + var existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() + // Old WorkManager jobs have no revision input. Resolve it as their owner + // and save it in Room so another attempt does not depend on a UI cache. + val revision = inputData.getInt(KEY_REVISION, 0).takeIf { it > 0 } + ?: existing?.record?.takeIf { it.id == downloadId }?.revision?.takeIf { it > 0 } + ?: when (val result = repository.resolveTransferRecord(downloadId, fileId, transferAuthority)) { + is ApiResult.Success -> { + val row = result.data + val sidecar = existing?.takeIf { it.record.id == downloadId } + ?: return@withContext Result.failure() + // Unversioned partial bytes cannot prove which registry target + // they came from. Start fresh before adopting this revision. + val restarted = sidecar.copy( + record = row.withWorkerStatus( + if (row.statusEnum() == DownloadStatus.Preparing) row.status else DownloadStatus.Ready.wire, + bytesSent = 0, + ), + localUri = null, + resumeValidator = null, + ) + ownedWrite { + storage.delete(serverId, profileId, fileId) + metadataStore.writeSidecar(serverId, profileId, restarted) + true + } + existing = restarted + checkNotNull(row.revision) + } + is ApiResult.NetworkError -> return@withContext Result.retry() + is ApiResult.Error -> return@withContext if (downloadStatusReportOutcome(result) == DownloadStatusReportOutcome.RetryLater) Result.retry() else Result.failure() + } + var transferStartedAtMs = 0L + // Resume state (survives process death + WorkManager retries): the partial's + // uri + the validator captured at download start. Resume offset is the REAL + // on-disk size (fd stat), never the metadata SIZE column (stale while pending). + val resumeUri = existing?.localUri + val resumeFrom = resumeUri?.let { storage.partialSize(it) } ?: 0L + val resumeValidator = existing?.resumeValidator + // Resume ONLY with a validator: an unvalidated Range append would silently + // corrupt the file if the source changed (the server can't tell us). No + // validator → behave as a fresh download (no Range header). + val canResume = existing?.record?.let { it.id == downloadId && it.revision == revision } == true && + resumeFrom > 0 && resumeUri != null && !resumeValidator.isNullOrBlank() + requireOwner() httpClient.prepareGet("/api/v2/downloads/${downloadId.encodeURLPathPart()}/file") { transferAuthority?.let { managedDownloadAuth(it.scope) } @@ -226,6 +260,9 @@ class DownloadWorker( out = fresh.openOutputStream() } + transferStartedAtMs = System.currentTimeMillis() + reportStatus(downloadId, DownloadStatus.Downloading, transferStartedAtMs, revision, serverId, profileId) + val channel = response.bodyAsChannel() val throttle = DownloadProgressThrottle() @@ -277,9 +314,6 @@ class DownloadWorker( } } - // Server flips status → completed when its serve handler returns; - // a refresh here ensures the cache reflects that before the - // worker exits and the UI re-renders. val pendingUri = activeUri ?: error("download target was not created") val finalBytes = storage.partialSize(pendingUri) // Publish the file and flip the sidecar to completed under one @@ -297,14 +331,30 @@ class DownloadWorker( fileSize = finalBytes, localUri = finalUri, resumeValidator = "", + revision = revision, ) true } Log.i(TAG, "doWork success id=$downloadId bytes=$finalBytes") DiagnosticsDownloadLogger.event("download completed") - // Server flips status → completed when its serve handler returns; - // refresh so the cache reflects that before the UI re-renders. - repository.refresh() + // Strictly after the `downloading` event: the server ignores an event + // that is not newer than the one it holds. + val completedAtMs = maxOf(System.currentTimeMillis(), transferStartedAtMs + 1) + // Only while the cached row still describes the bytes just fetched: a + // refresh may already hold a replacement revision, which stays as-is. + if (uiPushAllowed(serverId, profileId)) { + repository.recordForFile(fileId) + ?.takeIf { it.id == downloadId && (it.revision == null || it.revision == revision) } + ?.let { existing -> + repository.upsertLocal( + existing.withWorkerStatus(DownloadStatus.Completed.wire, bytesSent = finalBytes, fileSize = finalBytes) + .copy(completedAt = existing.completedAt ?: Instant.ofEpochMilli(completedAtMs).toString(), + revision = revision), + ) + } + } + // The v2 file route never marks the entry completed; this report does. + reportStatus(downloadId, DownloadStatus.Completed, completedAtMs, revision, serverId, profileId) Result.success(workDataOf(KEY_BYTES_WRITTEN to finalBytes, KEY_TOTAL_BYTES to finalBytes)) } catch (e: DownloadOwnerChanged) { // Preserve the original owner's partial; a new login cannot resume it. @@ -322,6 +372,9 @@ class DownloadWorker( Log.i(TAG, "doWork cancelled id=$downloadId") DiagnosticsDownloadLogger.event("download cancelled") withContext(NonCancellable) { + // WorkManager's notification action cancels this transfer directly. + // Its separate status job must stop before we remove the bytes. + runCatching { DownloadStatusWorker.cancel(appContext, downloadId) } // Delete by scope+fileId (not just activeUri): a cancel before the // response is classified leaves activeUri null but a prior attempt's // partial may still be on disk. @@ -373,6 +426,30 @@ class DownloadWorker( } } + /** + * Queue a status event for the server registry ([DownloadStatusWorker]). + * Skipped without a captured owner or a known revision: the server would + * refuse the event, and there is no owner to send it as. + */ + private fun reportStatus(downloadId: String, status: DownloadStatus, atMs: Long, revision: Int?, serverId: String, profileId: String) { + if (transferAuthority == null || revision == null) { + Log.w(TAG, "status report skipped id=$downloadId status=${status.wire} revision=$revision") + return + } + runCatching { + DownloadStatusWorker.enqueue( + context = appContext, + downloadId = downloadId, + event = DownloadStatusEvent(status.wire, Instant.ofEpochMilli(atMs).toString(), revision), + serverId = serverId, + profileId = profileId, + deviceId = inputData.getString(KEY_DEVICE_ID), + loginId = inputData.getString(KEY_LOGIN_ID), + origin = inputData.getString(KEY_ORIGIN), + ) + }.onFailure { Log.w(TAG, "status report enqueue failed id=$downloadId status=${status.wire}", it) } + } + /** Permanent failure — clean up local file and let the user retry manually. */ private suspend fun failPermanently( e: Throwable, @@ -387,6 +464,7 @@ class DownloadWorker( // Delete by scope+fileId so a partial from any attempt is cleaned up // even if this attempt failed before activeUri was assigned. runCatching { ownedWrite { storage.delete(serverId, profileId, fileId); true } } + runCatching { DownloadStatusWorker.cancel(appContext, downloadId) } // Best-effort: publish failed state into the repo + sidecar. val record = if (uiPushAllowed(serverId, profileId)) repository.recordForFile(fileId) else null if (record != null) { @@ -440,6 +518,7 @@ class DownloadWorker( localUri: String? = null, fileName: String? = null, resumeValidator: String? = null, + revision: Int? = null, ) { val existing = metadataStore.readSidecar(serverId, profileId, fileId) ?: return metadataStore.writeSidecar( @@ -449,7 +528,7 @@ class DownloadWorker( status = status, bytesSent = bytesSent, fileSize = fileSize, - ), + ).copy(revision = revision ?: existing.record.revision), localUri = localUri ?: existing.localUri, fileName = fileName?.takeIf { it.isNotBlank() } ?: existing.fileName, resumeValidator = when { @@ -522,6 +601,7 @@ class DownloadWorker( const val KEY_CONTAINER = "container" const val KEY_MEDIA_TYPE = "media_type" const val KEY_DISPLAY_TITLE = "display_title" + const val KEY_REVISION = "revision" const val KEY_BYTES_WRITTEN = "bytes" const val KEY_TOTAL_BYTES = "total" @@ -567,8 +647,10 @@ class DownloadWorker( deviceId: String? = null, loginId: String? = null, origin: String? = null, + revision: Int? = null, ) { val data = workDataOf( + KEY_REVISION to (revision ?: 0), KEY_DEVICE_ID to deviceId, KEY_LOGIN_ID to loginId, KEY_ORIGIN to origin, @@ -602,6 +684,7 @@ class DownloadWorker( fun cancel(context: Context, downloadId: String) { DownloadWorkerLifetime.beginCancellation(downloadId) WorkManager.getInstance(context).cancelAllWorkByTag(tagFor(downloadId)) + DownloadStatusWorker.cancel(context, downloadId) } /** @@ -611,6 +694,7 @@ class DownloadWorker( */ suspend fun cancelAndAwait(context: Context, downloadId: String) { DownloadWorkerLifetime.beginCancellation(downloadId) + DownloadStatusWorker.cancel(context, downloadId) withContext(Dispatchers.IO) { WorkManager.getInstance(context) .cancelUniqueWork(tagFor(downloadId)) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt index 7e269e7f2..6f4ffcdca 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt @@ -100,6 +100,26 @@ class SiloDatabaseMigrationTest { } } + @Test + fun migration11To12KeepsDownloadsWithAnUnknownRevision() { + val name = "migration-11-to-12" + migrationHelper.createDatabase(name, 11).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, mediaType, status, kind, " + + "fileSize, bytesSent, createdAt, updatedAtMs) VALUES ('s', 'p', 42, 'row', 'movie', 'Movie', 'movie', " + + "'completed', 'queued', 1024, 1024, '2026-09-29T00:00:00Z', 123)", + ) + } + migrationHelper.runMigrationsAndValidate(name, 12, true).use { database -> + database.query("SELECT recordId, status, revision FROM downloads").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("row", cursor.getString(0)); assertEquals("completed", cursor.getString(1)) + assertEquals(true, cursor.isNull(2)) + assertEquals(false, cursor.moveToNext()) + } + } + } + private companion object { const val DATABASE_NAME = "migration-7-to-8" } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadStatusReportOutcomeTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadStatusReportOutcomeTest.kt new file mode 100644 index 000000000..78e40d036 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadStatusReportOutcomeTest.kt @@ -0,0 +1,49 @@ +package org.siloserver.silo.common.downloads + +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.network.ApiResult + +class DownloadStatusReportOutcomeTest { + private val row = DownloadRecord(id = "dl", contentId = "movie", mediaFileId = 42, kind = "queued", + status = "completed", createdAt = "2026-09-29T00:00:00Z", revision = 1) + + private fun error(code: Int) = ApiResult.Error(code, "", "") + + @Test + fun `an accepted event is settled`() { + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome(ApiResult.Success(row))) + } + + @Test + fun `a moved revision reconciles instead of resending`() { + assertEquals(DownloadStatusReportOutcome.Reconcile, downloadStatusReportOutcome(error(409))) + } + + @Test + fun `offline and transient answers keep the event for later`() { + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(ApiResult.NetworkError(IOException("offline")))) + // A device clock ahead of the server's makes the event "future" until time catches up. + for (code in listOf(0, 400, 401, 408, 422, 429, 500, 502, 503)) { + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(error(code)), "HTTP $code") + } + } + + @Test + fun `answers a resend cannot change are settled`() { + for (code in listOf(403, 404, 410)) { + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome(error(code)), "HTTP $code") + } + } + + @Test + fun `a locked profile keeps its report until PIN verification succeeds`() { + val locked = ApiResult.Error(403, "profile_verification_required", "Verify the active profile.") + assertEquals(DownloadStatusReportOutcome.RetryLater, downloadStatusReportOutcome(locked)) + assertEquals(DownloadStatusReportOutcome.Settled, downloadStatusReportOutcome( + ApiResult.Error(403, "permission_denied", "Downloads are disabled."), + )) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 1f4b53f7c..8df2f18c3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -333,6 +333,15 @@ val androidModule = module { gate = get(), ) } + worker { + org.siloserver.silo.common.downloads.DownloadStatusWorker( + appContext = androidContext(), + params = get(), + repository = get(), + authorities = get(), + devices = get(), + ) + } worker { DownloadSubscriptionWorker( appContext = androidContext(), diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt index 3796a2de6..489c87525 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/downloads/AppWorkerFactory.kt @@ -10,6 +10,7 @@ import org.siloserver.silo.common.data.sync.SyncWorker import org.siloserver.silo.common.downloads.DownloadStorage import org.siloserver.silo.common.downloads.DownloadSubscriptionEvaluatorFactory import org.siloserver.silo.common.downloads.DownloadSubscriptionWorker +import org.siloserver.silo.common.downloads.DownloadStatusWorker import org.siloserver.silo.common.downloads.DownloadWorker import org.siloserver.silo.common.diagnostics.DiagnosticsCoordinator import org.siloserver.silo.common.diagnostics.DiagnosticsUploadWorker @@ -65,6 +66,16 @@ class AppWorkerFactory : WorkerFactory() { }, ) } + DownloadStatusWorker::class.java.name -> { + Log.i(TAG, "Building DownloadStatusWorker via Koin") + DownloadStatusWorker( + appContext = appContext, + params = workerParameters, + repository = koin.get(), + authorities = koin.get(), + devices = koin.get(), + ) + } DownloadSubscriptionWorker::class.java.name -> { Log.i(TAG, "Building DownloadSubscriptionWorker via Koin") DownloadSubscriptionWorker( diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt index c55f7e335..d10c875f3 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt @@ -328,10 +328,20 @@ class DownloadsViewModel( // contentId + MOVIES + "missing file" whenever the join missed). // Live byte-progress/status is overlaid from the in-memory server // records for in-flight items. Reload sidecars when a record the - // map doesn't know about appears (newly enqueued). + // map doesn't know about appears (newly enqueued), or when a + // download finished: the worker writes the completed sidecar + // (status + final file URI) before publishing the record, and + // the local completion is what the row's Ready/Play state reads. val (sections, bytesUsed) = withContext(Dispatchers.IO) { - if (records.any { it.id !in metadataByRecordId }) { + val unseenCompletions = records.filter { sidecarMissesCompletion(it) }.map { it.id } + if (unseenCompletions.isNotEmpty() || records.any { it.id !in metadataByRecordId }) { reloadSidecarMetadata() + // Reload once per disagreement: a row the server holds as + // completed while it downloads again locally must not + // re-read Room on every progress tick. + completionReloadIds = (completionReloadIds + unseenCompletions).filterTo(mutableSetOf()) { + metadataByRecordId[it]?.record?.statusEnum() != DownloadStatus.Completed + } } val sects = buildSections(records.associateBy { it.id }) sects to sects.sumOf { it.totalBytesUsed } @@ -635,6 +645,13 @@ class DownloadsViewModel( } } + /** Completed records whose sidecar still disagreed after a reload. */ + private var completionReloadIds: Set = emptySet() + + private fun sidecarMissesCompletion(record: DownloadRecord): Boolean = + record.statusEnum() == DownloadStatus.Completed && record.id !in completionReloadIds && + metadataByRecordId[record.id]?.record?.statusEnum()?.let { it != DownloadStatus.Completed } == true + private suspend fun activeDownloadScope(): Pair { val serverId = serverRegistry.activeServerId.value ?: DownloadEnqueuer.DEFAULT_SERVER_ID val profileId = withContext(Dispatchers.IO) { diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt index 4ed9d14c0..8d31b4dab 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadModels.kt @@ -167,6 +167,17 @@ enum class DownloadStatus(val wire: String) { } } +/** + * One local status report for one registry revision (`PATCH /api/v2/downloads/{id}`). + * [status] is `downloading` or `completed`; [updatedAt] is the RFC 3339 time the + * local state changed, not the send time. A retry must resend all three values. + */ +data class DownloadStatusEvent( + val status: String, + val updatedAt: String, + val revision: Int, +) + /** * `direct` = browser one-shot serve (no persistent server record). * `queued` = tracked record visible in the user's downloads list. diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DownloadsApi.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DownloadsApi.kt index e7031b1e0..05410d506 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DownloadsApi.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/api/DownloadsApi.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.network.api import org.siloserver.silo.model.download.DownloadCapability import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadRequest +import org.siloserver.silo.model.download.DownloadStatusEvent import org.siloserver.silo.model.download.DownloadsListResponse import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot @@ -34,6 +35,9 @@ open class DownloadsApi( open suspend fun delete(id: String, scope: AuthScopeSnapshot?): ApiResult = if (scope == null) changed() else registry.delete(id, scope) + open suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot?): ApiResult = + if (scope == null) changed() else registry.reportStatus(id, event, scope) + /** * Feature detection (issue #20 §3). Call at detail load / profile switch; * the picker offers only `quality_presets` and hides bitrate presets when diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Api.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Api.kt index 2938bbcb4..217f1c65a 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Api.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Api.kt @@ -5,6 +5,8 @@ import io.ktor.client.request.* import io.ktor.http.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.siloserver.silo.model.download.* import org.siloserver.silo.network.* @@ -48,7 +50,9 @@ class DownloadRegistryV2Api(private val client: HttpClient, private val tokens: client.request(path) { this.method = method; authScope(owner!!); requireSiloAuth() // The existing auth plugin attaches the installation's device metadata. - if (method != HttpMethod.Get) singleAttempt() + // A status event retains its revision and timestamp, so replaying + // PATCH after an auth refresh is safe. DELETE remains one attempt. + if (method == HttpMethod.Delete) singleAttempt() configure() } }) { it } @@ -93,4 +97,29 @@ class DownloadRegistryV2Api(private val client: HttpClient, private val tokens: val device = devices.current()?.id ?: return identityChanged() return exchange(scope, device, HttpMethod.Delete, "/api/v2/downloads/${id.encodeURLPathPart()}") } + + /** + * `PATCH /downloads/{id}`: one revision-bound local status event. [updatedAt] is when the + * local state changed, not when this is sent; a retry resends the same event, which the + * server acknowledges without changing the entry. A moved revision answers `409`. + */ + suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot): ApiResult { + val device = devices.current()?.id?.takeIf { it.isNotBlank() } ?: return identityChanged() + if (id.isBlank() || event.revision < 1 || event.updatedAt.isBlank() || + event.status != DownloadStatus.Downloading.wire && event.status != DownloadStatus.Completed.wire) return invalid() + val result = exchange(scope, device, HttpMethod.Patch, "/api/v2/downloads/${id.encodeURLPathPart()}") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { + put("status", event.status); put("updated_at", event.updatedAt); put("revision", event.revision) + }) + } + return when (result) { + is ApiResult.Success -> { + val row = try { result.data.project(device) } catch (_: IllegalArgumentException) { return invalid() } + if (row.id == id) ApiResult.Success(row) else invalid() + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DownloadsRepository.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DownloadsRepository.kt index aac1b195d..2ea57f5ea 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DownloadsRepository.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/repository/DownloadsRepository.kt @@ -3,10 +3,14 @@ package org.siloserver.silo.repository import org.siloserver.silo.model.download.DownloadCapability import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadRequest +import org.siloserver.silo.model.download.DownloadStatus +import org.siloserver.silo.model.download.DownloadStatusEvent +import org.siloserver.silo.model.download.statusEnum import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.api.DownloadsApi import org.siloserver.silo.repository.port.DownloadDeletionPort import org.siloserver.silo.repository.port.NoOpDownloadDeletionPort +import org.siloserver.silo.util.parseRfc3339ToEpochMillis import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,9 +25,12 @@ import kotlinx.coroutines.flow.update * **Disk-as-truth (v1.1).** The Android side seeds this cache from the * on-disk sidecar files at startup via [seedFromSidecars]. [refresh] then * *merges* the server view into that cache instead of overwriting it — - * server-known records win on conflict (fresher status), but records that - * only exist on disk (server cleaned them up while we were offline, or we - * marked them [pendingDelete] but haven't synced yet) stay visible. + * server-known records win on conflict (fresher status), except that a + * locally completed download is never downgraded to the server's `ready` / + * `downloading` for the same revision before its completion report lands + * ([keepLocalCompletion]). + * Records that only exist on disk (server cleaned them up while we were + * offline, or we marked them [pendingDelete] but haven't synced yet) stay visible. * * **Two-phase delete.** The server has a quirky DELETE: for active records * (queued / downloading) it only flips status to cancelled rather than @@ -137,7 +144,11 @@ class DownloadsRepository( if (!localWrite(authority) { val hidden = pendingDelete + durable val serverList = result.data.downloads.filterNot { it.id in hidden } - _records.update { current -> serverList + current.filter { it.id !in serverIds && it.id !in hidden && it.id in keepIdsAbsentFromServer } } + _records.update { current -> + val local = current.associateBy { it.id } + serverList.map { keepLocalCompletion(it, local[it.id]) } + + current.filter { it.id !in serverIds && it.id !in hidden && it.id in keepIdsAbsentFromServer } + } }) return changed() if (serverId != null && profileId != null) { val deviceId = devices?.current()?.id @@ -174,6 +185,60 @@ class DownloadsRepository( } + /** Read the revision for an older queued transfer without relying on the UI cache. */ + suspend fun resolveTransferRecord( + id: String, + fileId: Int, + expectedAuthority: org.siloserver.silo.network.DurableLoginAuthority? = null, + ): ApiResult { + val authority = expectedAuthority ?: authorities?.snapshotDurableLoginAuthority() + if (!current(authority)) return changed() + val result = api.list(authority?.scope) + if (!current(authority)) return changed() + return when (result) { + is ApiResult.Success -> { + val row = result.data.downloads.singleOrNull { it.id == id } + ?: return ApiResult.Error(404, "download_not_found", "The queued download no longer exists.") + if (row.mediaFileId != fileId) return ApiResult.Error(409, "download_target_changed", "The queued download now targets another file.") + if (row.revision == null || row.revision < 1) return ApiResult.Error(0, "invalid_download_registry", "The download revision is unavailable.") + ApiResult.Success(row) + } + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + } + + /** + * Reports one local status event under [expectedAuthority] (the owner the + * download ran for) and records the server's acknowledgement in the cache. + * The result is returned as-is so the caller can tell a moved revision (409) + * from a transient failure. + */ + suspend fun reportStatus( + id: String, + event: DownloadStatusEvent, + expectedAuthority: org.siloserver.silo.network.DurableLoginAuthority? = null, + ): ApiResult { + val authority = expectedAuthority ?: authorities?.snapshotDurableLoginAuthority() + if (!current(authority)) return changed() + val result = api.reportStatus(id, event, authority?.scope) + if (result is ApiResult.Success && !localWrite(authority) { + // The worker publishes local status itself, and an answer can land + // after the transfer moved on (completed, or failed and cleaned up), + // or after a refresh installed a replacement revision. Only the + // acknowledgement is taken from it, and only for the same revision. + val row = result.data + _records.update { list -> + list.map { + if (it.id != id || (it.revision != null && it.revision != row.revision)) it + else it.copy(completedAt = row.completedAt ?: it.completedAt, revision = row.revision, + statusEventAt = latestInstant(it.statusEventAt, row.statusEventAt)) + } + } + }) return changed() + return result + } + /** * Series-batch creation (one POST → N records sharing a batchId). All * returned records are upserted so the Downloads tab + per-row UI @@ -297,6 +362,32 @@ class DownloadsRepository( } } + /** + * A download this device finished stays completed while the server still + * reports it as `ready`/`downloading`: the completion report may not have + * reached it yet (offline, or still queued). The completion only speaks for + * the bytes it fetched, so the server row must be the same revision. A local + * row without a stored revision (saved before it was kept) proves nothing, + * and a replaced revision makes the server row win. + */ + internal fun keepLocalCompletion(server: DownloadRecord, local: DownloadRecord?): DownloadRecord { + if (local == null || local.statusEnum() != DownloadStatus.Completed) return server + if (server.statusEnum() != DownloadStatus.Ready && server.statusEnum() != DownloadStatus.Downloading) return server + if (local.revision == null || local.revision != server.revision) return server + return server.copy(status = local.status, bytesSent = local.bytesSent, completedAt = server.completedAt ?: local.completedAt) + } + + /** Compare event times at the client's millisecond precision, regardless of timestamp shape. */ + private fun latestInstant(a: String?, b: String?): String? { + val aMillis = a?.let(::parseRfc3339ToEpochMillis) + val bMillis = b?.let(::parseRfc3339ToEpochMillis) + return when { + bMillis == null -> a + aMillis == null || bMillis > aMillis -> b + else -> a + } + } + /** Lookup helper for callers (item detail) that key off `FileVersion.fileId`. */ fun recordForFile(fileId: Int): DownloadRecord? = _records.value.firstOrNull { it.mediaFileId == fileId } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Test.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Test.kt index 5f7a65b62..334a45452 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Test.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/network/apiv2/DownloadRegistryV2Test.kt @@ -81,6 +81,92 @@ class DownloadRegistryV2Test { } finally { c.close() } } + @Test fun reportStatusPatchesTheRevisionBoundEvent() = runTest { + val event = DownloadStatusEvent("completed", "2026-01-02T03:04:05.000Z", 1) + var answer: MockRequestHandleScope.() -> io.ktor.client.request.HttpResponseData = { + reply(row.replace("\"ready\"", "\"completed\"").replace("\"revision\":1", "\"revision\":1,\"status_event_at\":\"2026-01-02T03:04:05.000Z\"")) + } + var sent = 0 + val c = client { + sent++ + assertEquals(HttpMethod.Patch, it.method) + assertEquals("/api/v2/downloads/one", it.url.encodedPath) + assertEquals(scope, it.attributes[AuthScopeAttributeKey]) + val body = SiloJson.parseToJsonElement((it.body as io.ktor.http.content.TextContent).text) + assertEquals(SiloJson.parseToJsonElement("""{"status":"completed","updated_at":"2026-01-02T03:04:05.000Z","revision":1}"""), body) + answer() + } + try { + val api = DownloadRegistryV2Api(c,tokens,devices, ApiV2Gate.Unrestricted) + val answered = assertIs>(api.reportStatus("one", event, scope)).data + assertEquals("completed", answered.status); assertEquals("2026-01-02T03:04:05.000Z", answered.statusEventAt) + + answer = { respond("""{"code":"conflict","detail":"The download revision changed."}""", HttpStatusCode.Conflict, headersOf(HttpHeaders.ContentType, "application/problem+json")) } + assertEquals(409, assertIs(api.reportStatus("one", event, scope)).code) + + // An answer for another entry is not this event's receipt. + answer = { reply(row.replace("\"one\"", "\"two\"")) } + assertIs(api.reportStatus("one", event, scope)) + + // Events the server would refuse are never sent. + val before = sent + assertIs(api.reportStatus("one", event.copy(revision = 0), scope)) + assertIs(api.reportStatus("one", event.copy(status = "ready"), scope)) + assertEquals(before, sent) + } finally { c.close() } + } + + @Test fun delayedStatusRefreshesItsOwnerAndResendsTheRetainedEvent() = runTest { + var access = "expired-access" + var refreshedOwner: AuthScopeSnapshot? = null + val authenticated = object : TokenManager by TokenManagerImpl() { + override suspend fun snapshotCurrentScope() = scope + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot) = access + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot) = "owner-refresh" + override suspend fun saveTokensForScope(scope: AuthScopeSnapshot, accessToken: String, refreshToken: String, expiresIn: Long) { + refreshedOwner = scope + access = accessToken + } + override suspend fun invalidateSession() = fail("A background report must retain its owner's session.") + } + val bodies = mutableListOf() + val bearers = mutableListOf() + var refreshes = 0 + val c = HttpClient(MockEngine { request -> + assertEquals("example.invalid", request.url.host) + when (request.url.encodedPath) { + "/api/v2/auth/refresh" -> { + refreshes++ + reply("""{"access_token":"rotated-access","refresh_token":"rotated-refresh","expires_in":900}""") + } + "/api/v2/downloads/one" -> { + assertEquals(HttpMethod.Patch, request.method) + assertEquals(scope, request.attributes[AuthScopeAttributeKey]) + bodies += request.body.toByteArray().decodeToString() + bearers += request.headers[HttpHeaders.Authorization] + if (bearers.size == 1) respond("""{"code":"invalid_token","detail":"Expired."}""", + HttpStatusCode.Unauthorized, headersOf(HttpHeaders.ContentType, "application/problem+json")) + else reply(row.replace("\"ready\"", "\"completed\"")) + } + else -> fail("Unexpected request ${request.url}") + } + }) { + install(ContentNegotiation) { json(SiloJson) } + install(SiloAuthPlugin) { tokenManager = authenticated; deviceMetadataProvider = devices } + } + try { + val event = DownloadStatusEvent("completed", "2026-01-02T03:04:05.000Z", 1) + assertIs>( + DownloadRegistryV2Api(c, authenticated, devices, ApiV2Gate.Unrestricted).reportStatus("one", event, scope), + ) + assertEquals(1, refreshes) + assertEquals(scope, refreshedOwner) + assertEquals>(listOf("Bearer expired-access", "Bearer rotated-access"), bearers) + assertEquals(2, bodies.size) + assertEquals(bodies[0], bodies[1]) + } finally { c.close() } + } + @Test fun capabilityRequiresVersionedStateAndFailsClosed() = runTest { var body = """{"revision":"rev","state":"future","enabled":true,"download_allowed":true}""" val c = client { reply(body) } diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DownloadsRepositoryCompletionTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DownloadsRepositoryCompletionTest.kt new file mode 100644 index 000000000..04edd6fee --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/repository/DownloadsRepositoryCompletionTest.kt @@ -0,0 +1,287 @@ +package org.siloserver.silo.repository + +import io.ktor.client.HttpClient +import kotlinx.coroutines.test.runTest +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadStatusEvent +import org.siloserver.silo.model.download.DownloadsListResponse +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.DeviceMetadataProvider +import org.siloserver.silo.network.DurableLoginAuthority +import org.siloserver.silo.network.DurableLoginAuthorityProvider +import org.siloserver.silo.network.SiloDeviceMetadata +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.DownloadsApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.DownloadCreationV2Api +import org.siloserver.silo.network.apiv2.DownloadRegistryV2Api +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +private object CompletionTestNoDevices : DeviceMetadataProvider { + override suspend fun current(): SiloDeviceMetadata? = null +} + +private fun registryStub() = DownloadRegistryV2Api(HttpClient(), TokenManagerImpl(), CompletionTestNoDevices, ApiV2Gate.Unrestricted) + +private class RegistryFake : DownloadsApi( + registry = registryStub(), + tokens = TokenManagerImpl(), + creation = DownloadCreationV2Api(HttpClient(), TokenManagerImpl(), CompletionTestNoDevices, registryStub(), ApiV2Gate.Unrestricted), +) { + var server: List = emptyList() + var onList: (() -> Unit)? = null + val listedScopes = mutableListOf() + var reportAnswer: (DownloadStatusEvent) -> ApiResult = { ApiResult.NetworkError(IllegalStateException("offline")) } + val reports = mutableListOf>() + + override suspend fun list(scope: AuthScopeSnapshot?): ApiResult { + listedScopes += scope + onList?.invoke() + return ApiResult.Success(DownloadsListResponse(server)) + } + + override suspend fun reportStatus(id: String, event: DownloadStatusEvent, scope: AuthScopeSnapshot?): ApiResult { + reports += id to event + return reportAnswer(event) + } +} + +private fun entry(status: String, revision: Int? = 1, bytes: Long = 0, completedAt: String? = null, quality: String? = "original") = DownloadRecord( + id = "dl", contentId = "movie", mediaFileId = 42, fileSize = 1000, bytesSent = bytes, kind = "queued", + status = status, createdAt = "2026-09-29T00:00:00Z", completedAt = completedAt, revision = revision, + quality = quality, effectiveQuality = quality, +) + +class DownloadsRepositoryCompletionTest { + + @Test + fun `an old queued job resolves its revision with an empty UI cache`() = runTest { + val owner = DurableLoginAuthority("login", AuthScopeSnapshot("server", "profile", "https://example.invalid", "proof")) + val authorities = object : DurableLoginAuthorityProvider { override suspend fun snapshotDurableLoginAuthority() = owner } + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 7)) } + val repo = DownloadsRepository(api, authorities = authorities) + + val row = assertIs>(repo.resolveTransferRecord("dl", 42, owner)).data + + assertEquals(7, row.revision) + assertEquals(listOf(owner.scope), api.listedScopes) + assertEquals(emptyList(), repo.records.value) + } + + @Test + fun `an old job cannot resolve a replacement targeting another file`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2).copy(mediaFileId = 99)) } + assertEquals(409, assertIs(DownloadsRepository(api).resolveTransferRecord("dl", 42)).code) + assertEquals(404, assertIs(DownloadsRepository(api).resolveTransferRecord("gone", 42)).code) + } + + @Test + fun `an old job cannot use a revision returned after its owner changed`() = runTest { + val owner = DurableLoginAuthority("login", AuthScopeSnapshot("server", "profile", "https://example.invalid", null)) + var active = owner + val authorities = object : DurableLoginAuthorityProvider { override suspend fun snapshotDurableLoginAuthority() = active } + val api = RegistryFake().apply { + server = listOf(entry("ready")) + onList = { active = owner.copy(loginId = "other-login") } + } + val repo = DownloadsRepository(api, authorities = authorities) + + assertEquals("identity_changed", assertIs(repo.resolveTransferRecord("dl", 42, owner)).error) + assertEquals(emptyList(), repo.records.value) + } + + @Test + fun `refresh keeps a local completion while the server still says ready`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready")) } + val repo = DownloadsRepository(api) + repo.refresh() + // The worker publishes the finished file before the server hears about it. + repo.upsertLocal(entry("completed", bytes = 1000, completedAt = "2026-09-29T00:01:00Z")) + + repo.refresh() + + val row = repo.records.value.single() + assertEquals("completed", row.status) + assertEquals(1000, row.bytesSent) + assertEquals("2026-09-29T00:01:00Z", row.completedAt) + } + + @Test + fun `refresh keeps a completion seeded from local metadata with the same revision`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("downloading")) } + val repo = DownloadsRepository(api) + repo.seedFromSidecars(listOf(entry("completed", bytes = 1000))) + + repo.refresh() + + assertEquals("completed", repo.records.value.single().status) + } + + @Test + fun `a replaced server revision wins over the old local completion`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2)) } + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", revision = 1, bytes = 1000)) + + repo.refresh() + + val row = repo.records.value.single() + assertEquals("ready", row.status) + assertEquals(2, row.revision) + } + + @Test + fun `a completion saved without a revision does not cover any server revision`() = runTest { + // Same file and quality: a failed row re-created at revision 2 keeps its target. + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2)) } + val repo = DownloadsRepository(api) + repo.seedFromSidecars(listOf(entry("completed", revision = null, bytes = 1000))) + + repo.refresh() + + assertEquals("ready", repo.records.value.single().status) + } + + @Test + fun `server failure states are not masked by a local completion`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("failed")) } + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + + repo.refresh() + + assertEquals("failed", repo.records.value.single().status) + } + + @Test + fun `a completion report records the server acknowledgement and keeps local state`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + val event = DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1) + api.reportAnswer = { entry("completed", completedAt = "2026-09-29T00:01:00Z").copy(statusEventAt = it.updatedAt).let { ApiResult.Success(it) } } + + assertIs>(repo.reportStatus("dl", event)) + + assertEquals(listOf("dl" to event), api.reports) + val row = repo.records.value.single() + assertEquals("completed", row.status) + assertEquals(1000, row.bytesSent) + assertEquals("2026-09-29T00:01:00Z", row.completedAt) + assertEquals("2026-09-29T00:01:00.000Z", row.statusEventAt) + } + + @Test + fun `a late downloading answer does not undo a local completion`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + api.reportAnswer = { ApiResult.Success(entry("downloading")) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + assertEquals("completed", repo.records.value.single().status) + } + + @Test + fun `a late downloading answer does not revive a failed transfer`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + // The worker failed permanently and cleaned up after the report was sent. + repo.upsertLocal(entry("failed")) + api.reportAnswer = { ApiResult.Success(entry("downloading").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + val row = repo.records.value.single() + assertEquals("failed", row.status) + assertEquals("2026-09-29T00:00:30.000Z", row.statusEventAt) + } + + @Test + fun `an acknowledgement for a superseded revision leaves the replacement alone`() = runTest { + val api = RegistryFake().apply { server = listOf(entry("ready", revision = 2, quality = "2mbps")) } + val repo = DownloadsRepository(api) + repo.refresh() + api.reportAnswer = { ApiResult.Success(entry("completed", completedAt = "2026-09-29T00:01:00Z").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1)) + + assertEquals(entry("ready", revision = 2, quality = "2mbps"), repo.records.value.single()) + } + + @Test + fun `an older acknowledgement keeps the newer status event time`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = "2026-09-29T00:01:00.000Z")) + api.reportAnswer = { ApiResult.Success(entry("downloading").copy(statusEventAt = it.updatedAt)) } + + repo.reportStatus("dl", DownloadStatusEvent("downloading", "2026-09-29T00:00:30.000Z", 1)) + + assertEquals("2026-09-29T00:01:00.000Z", repo.records.value.single().statusEventAt) + } + + @Test + fun `acknowledgements compare whole and fractional seconds chronologically`() = runTest { + val whole = "2026-09-29T00:01:00Z" + val fractional = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf(whole to fractional, fractional to whole)) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", acknowledged, 1)) + + assertEquals(fractional, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `acknowledgements compare instants across numeric offsets`() = runTest { + val older = "2026-09-29T01:01:00+01:00" + val newer = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf(older to newer, newer to older)) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", acknowledged, 1)) + + assertEquals(newer, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `an invalid timestamp cannot replace a valid acknowledgement`() = runTest { + val valid = "2026-09-29T00:01:00.500Z" + for ((cached, acknowledged) in listOf("invalid" to valid, valid to "invalid")) { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000).copy(statusEventAt = cached)) + api.reportAnswer = { ApiResult.Success(entry("completed").copy(statusEventAt = acknowledged)) } + + repo.reportStatus("dl", DownloadStatusEvent("completed", valid, 1)) + + assertEquals(valid, repo.records.value.single().statusEventAt) + } + } + + @Test + fun `a failed report leaves the cache untouched`() = runTest { + val api = RegistryFake() + val repo = DownloadsRepository(api) + repo.upsertLocal(entry("completed", bytes = 1000)) + api.reportAnswer = { ApiResult.Error(409, "conflict", "revision changed") } + + val result = repo.reportStatus("dl", DownloadStatusEvent("completed", "2026-09-29T00:01:00.000Z", 1)) + + assertEquals(409, assertIs(result).code) + assertEquals(entry("completed", bytes = 1000), repo.records.value.single()) + } +} From bb52debb699fa0e5eed8437ecc34d248237491ac Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:21:47 -0400 Subject: [PATCH 09/10] fix(tv): focus the request detail and take one step per Back press (#414) * fix(tv): focus the request detail and return to My Requests on Back The Android TV request detail had nothing focusable once a title was already requested: the status was plain text. Opening it from My Requests left focus on the top bar's Search button. Back then skipped My Requests because one press ran the shell's Back twice on Android 16. For apps targeting API 36 the platform invokes the top back callback and then still delivers the same KeyUp to the view tree, marked canceled. The shell's key fallback acted on that canceled KeyUp: the detail popped to My Requests, then the shell ran again with focus on the bar and went Home. The same double run made the first Back from tab content go straight Home instead of to the bar. - The detail now shows one primary action pill in every state (Request, Requesting, or the request status) and claims focus on it once the title loads, like the tvOS single primary action. The pill stays in place across a submit, so focus is not dropped when Request turns into the status. - The shell's key fallback swallows a canceled Back KeyUp instead of acting on it. Android 15 and older deliver an uncanceled key and are unaffected. Back from a detail opened from My Requests now returns to My Requests with the opened row focused, through the list's existing return restoration. Refs #411 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): show the request detail's status pill as not actionable The request detail's primary pill was enabled whenever no submit was in flight, so an already requested or unavailable title showed its status as a button whose Select did nothing. Enable it only while the title is requestable and not submitting. A disabled androidx.tv Surface stays focusable (its clickable modifier is focusable regardless of enabled), so the page keeps its focus target and Back from the detail still returns to My Requests. Refs #411 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(tv): keep focus where the viewer put it while a request detail loads The request detail claimed focus for its primary pill whenever the detail arrived and the page did not already hold focus. During a slow load the page has nothing focusable, so focus sat on the top bar. A viewer who moved along the bar or opened the profile menu in that window had focus pulled back to the pill when the detail landed. The claim could not simply skip when the bar owns focus: once the row that opened the page is disposed, Compose re-homes focus onto the bar's Search button on its own, and that is the case the claim exists for. - While the detail loads, focus waits on a spinner-sized target inside the page, so a move to the bar in that window is the viewer's own. - When the detail arrives, focus moves to the pill only if it is still in the page, or never reached it. The detail's own Back handler also yields while the shell has a cascade panel or the profile menu open. On Android 16 it outranked the shell's handler and popped the page from under the overlay, leaving the panel open over My Requests. The previous commit's canceled-KeyUp guard removed the accidental second shell run that used to close it. Refs #411 Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Codex --- .../screens/requests/TvRequestDetailScreen.kt | 156 ++++++++++++++---- .../silo/tv/ui/shell/TvMainShell.kt | 16 +- 2 files changed, 138 insertions(+), 34 deletions(-) diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt index 3f8c41728..02de68d41 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/requests/TvRequestDetailScreen.kt @@ -2,6 +2,7 @@ package org.siloserver.silo.tv.ui.screens.requests import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,10 +18,19 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -35,11 +45,16 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.siloserver.silo.common.ui.components.ThumbhashImage import org.siloserver.silo.model.request.RequestMediaDetail +import org.siloserver.silo.model.request.RequestState import org.siloserver.silo.model.request.reasonMessage import org.siloserver.silo.model.request.requestBackdropUrl +import org.siloserver.silo.model.request.requestDisplayLabel import org.siloserver.silo.model.request.requestPosterUrl import org.siloserver.silo.tv.ui.components.TvErrorScreen import org.siloserver.silo.tv.ui.components.TvLoadingScreen +import org.siloserver.silo.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.siloserver.silo.tv.ui.focus.TvObservedFocusResult +import org.siloserver.silo.tv.ui.focus.requestFocusUntilObserved import org.siloserver.silo.tv.ui.theme.RowDimens import org.siloserver.silo.tv.ui.theme.SiloBlue import org.siloserver.silo.tv.ui.theme.cardScaled @@ -51,8 +66,8 @@ import org.koin.core.parameter.parametersOf /** * TV request detail — the 10-foot counterpart to the phone RequestDetailScreen. * Reuses the shared [RequestDetailViewModel] (load + submitRequest) keyed by - * (mediaType, tmdbId). Shows title/metadata/genres/overview and a Request - * action when the title is requestable, plus the current request status. + * (mediaType, tmdbId). Shows title/metadata/genres/overview and one primary + * action: Request when the title is requestable, otherwise the request status. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -60,19 +75,71 @@ fun TvRequestDetailScreen( mediaType: String, tmdbId: Int, onBack: () -> Unit, + /** + * False while the shell has an overlay (a cascade panel or the profile + * menu) that Back should close first. This handler registers after the + * shell's, so on Android 16 it would otherwise take that press and pop + * the page from under the open overlay. + */ + backEnabled: Boolean = true, + onInitialContentFocus: () -> Unit = {}, viewModel: RequestDetailViewModel = koinViewModel { parametersOf(mediaType, tmdbId) }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val loadingFocusRequester = remember { FocusRequester() } + val primaryActionFocusRequester = remember { FocusRequester() } + var pageHasFocus by remember { mutableStateOf(false) } + var pageHadFocus by remember { mutableStateOf(false) } + val showsLoading = state.isLoading && state.detail == null + // The title, not the loaded detail: the refresh after a submit replaces the + // detail and must not re-run the claim below. + val detailKey = state.detail?.let { "${it.mediaType}:${it.tmdbId}" } + val detailArrived by rememberUpdatedState(detailKey != null) - BackHandler(enabled = true) { onBack() } + // Nothing here is focusable until the detail loads, and the row that opened + // the page is about to be disposed, so Compose would re-home focus onto the + // top bar's Search button. Hold it on the loading indicator instead: focus + // stays in the page, and a move to the bar while this loads is the viewer's. + LaunchedEffect(showsLoading) { + if (!showsLoading) return@LaunchedEffect + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = loadingFocusRequester::requestFocus, + isFocused = { pageHasFocus || detailArrived }, + ) + } + + // Hand focus to the primary action when the detail arrives, but only if it + // is still in the page (or never got here). A viewer who went up to the bar + // or into the profile menu while this loaded keeps their place. Read in the + // composition that swaps the indicator for the content, so it is the focus + // from before the swap. + val claimOnArrival = remember(detailKey) { pageHasFocus || !pageHadFocus } + LaunchedEffect(detailKey) { + if (detailKey == null || !claimOnArrival) return@LaunchedEffect + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = primaryActionFocusRequester::requestFocus, + isFocused = { pageHasFocus }, + ) + if (result == TvObservedFocusResult.Focused) onInitialContentFocus() + } + + BackHandler(enabled = backEnabled) { onBack() } Column( modifier = Modifier .fillMaxSize() + .onFocusChanged { + pageHasFocus = it.hasFocus + if (it.hasFocus) pageHadFocus = true + } .background(MaterialTheme.colorScheme.background), ) { when { - state.isLoading && state.detail == null -> TvLoadingScreen() + showsLoading -> RequestDetailLoading(focusRequester = loadingFocusRequester) state.error != null && state.detail == null -> TvErrorScreen( message = state.error ?: "Failed to load this title.", onRetry = viewModel::load, @@ -83,11 +150,30 @@ fun TvRequestDetailScreen( notice = state.notice, error = state.error, onRequest = viewModel::submitRequest, + primaryActionFocusRequester = primaryActionFocusRequester, ) } } } +/** + * The loading indicator plus a spinner-sized focus target over it, so focus + * can wait inside the page. Kept small and centred: D-pad Up from it has to + * find the top bar by geometry, which a full-screen target would not. + */ +@Composable +private fun RequestDetailLoading(focusRequester: FocusRequester) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + TvLoadingScreen() + Box( + modifier = Modifier + .size(64.dp) + .focusRequester(focusRequester) + .focusable(), + ) + } +} + /** * Content starts below the shell's top navigation, which is a `TopStart` * overlay drawn over every screen rather than something that reserves space. @@ -115,6 +201,7 @@ private fun RequestDetailContent( notice: String?, error: String?, onRequest: () -> Unit, + primaryActionFocusRequester: FocusRequester, ) { Box(modifier = Modifier.fillMaxSize()) { // Backdrop, scrimmed hard enough that body copy stays legible over the @@ -233,35 +320,25 @@ private fun RequestDetailContent( ) } + // One pill in every state, never swapped for another node. The + // status used to be plain text, which left the page with nothing + // to focus, and replacing Request with it after a submit dropped + // the focused node the same way. Only Request is enabled; the + // status or reason renders disabled so it reads as not + // actionable. A disabled TV Surface still takes focus (its + // clickable is focusable regardless of enabled), so the page + // keeps a focus target in every state, like tvOS + // RequestDetailView's single primary action. val request = detail.request - val reasonMessage = request.reasonMessage() - when { - request.requestable -> { - TvRequestActionPill( - label = if (isSubmitting) "Requesting…" else "Request", - icon = Icons.Filled.Add, - onClick = onRequest, - enabled = !isSubmitting, - modifier = Modifier.padding(top = 12.dp), - ) - } - !request.status.isNullOrBlank() -> { - Text( - text = "Request status: ${request.status}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 12.dp), - ) - } - reasonMessage != null -> { - Text( - text = reasonMessage, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 12.dp), - ) - } - } + TvRequestActionPill( + label = request.primaryActionLabel(isSubmitting), + icon = Icons.Filled.Add.takeIf { request.requestable }, + onClick = onRequest, + enabled = request.requestable && !isSubmitting, + modifier = Modifier + .padding(top = 12.dp) + .focusRequester(primaryActionFocusRequester), + ) notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) @@ -273,3 +350,18 @@ private fun RequestDetailContent( } } } + +/** + * Label for the detail's primary action: Request while the title is + * requestable, otherwise the existing request's status, otherwise the reason + * it cannot be requested. Status tokens use [requestDisplayLabel]; reasons use + * the shared [reasonMessage] policy for readable sentences and unknown codes. + */ +private fun RequestState.primaryActionLabel(isSubmitting: Boolean): String { + if (requestable) return if (isSubmitting) "Requesting…" else "Request" + val status = status?.takeIf { it.isNotBlank() } + return when { + status != null -> "Request status: ${status.requestDisplayLabel()}" + else -> reasonMessage() ?: "Unavailable" + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt index 81384afe7..f9f3d70f9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/shell/TvMainShell.kt @@ -976,7 +976,8 @@ fun TvMainShell( } } - // Android 16 no longer dispatches KEYCODE_BACK to apps targeting API 36. + // Android 16 routes KEYCODE_BACK for apps targeting API 36 to back + // callbacks first (the key reaches views only as a canceled KeyUp). // Register the shell's stateful routing through the supported callback and // enable it only when this layer can consume the press, so child callbacks // and the activity fallback retain their existing priority. @@ -1036,7 +1037,14 @@ fun TvMainShell( if (ev.type == KeyEventType.KeyUp && (ev.key == Key.Back || ev.key == Key.Escape) ) { - handleShellBack() + // Android 16 still delivers the key: after invoking the + // top back callback it forwards the same KeyUp marked + // canceled. Acting on it ran the shell's Back a second + // time for one press, so Back from a pushed screen + // (a request detail, My Requests) skipped its parent. + // A canceled up must not perform the key's action, so + // swallow it rather than let it reach anything below. + if (ev.nativeKeyEvent.isCanceled) true else handleShellBack() } else { false } @@ -1339,6 +1347,10 @@ fun TvMainShell( mediaType = entry.arguments?.getString(TvMainRoute.RequestDetail.ARG_MEDIA_TYPE).orEmpty(), tmdbId = entry.arguments?.getInt(TvMainRoute.RequestDetail.ARG_TMDB_ID) ?: 0, onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, + // Back closes an open panel or profile menu first, as + // on every other screen; the shell's handler does that. + backEnabled = focusState.openPanel == null && !focusState.profileMenuOpen, + onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } shellComposable(TvMainRoute.Collections.route) { From eb1e2edb2e990f95707692974b547895b331fd07 Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 29 Sep 2026 22:40:19 +0200 Subject: [PATCH 10/10] fix(downloads): play every track of server-prepared downloads offline (#417) * fix(downloads): play every track of server-prepared downloads offline Server-prepared (remux/transcode) downloads now carry every audio track and embedded subtitle, with ASS/SSA and PGS as manifest sidecar files. Capture the offline manifest's audio list and subtitle sidecars when a download completes, attach the sidecars offline, list the file's own text tracks, select re-encoded audio by position, and keep catalog identity matching for original downloads. * fix(downloads): retry a transient offline manifest failure The offline track capture runs once, right after a download completes. Retry the manifest request briefly on network errors and 5xx/429 responses so a blip at that moment does not leave the download without its tracks. * fix(player): keep offline subtitle choices off the server Offline subtitle rows describe the downloaded file, so saving one as the item's preference would not resolve online and would suppress online auto-selection. Offline subtitle preferences now come from the cached active profile before falling back to the bounded server read. * fix(player): never wait on the server for offline subtitle preferences Offline playback takes subtitle preferences from the cached active profile only. A cache miss offline almost always means the server is unreachable, so the bounded profile read only delayed the local file. * fix(player): keep offline preference sources consistent with online Settings writes now refresh the cached active profile that offline subtitle preferences read. Audio picks in a server-prepared download are no longer saved, because its re-encoded tracks do not match the source's tracks online. * fix(settings): update the cached profile in place after a settings write A background profile refresh could be cancelled when Settings closed, finish out of order, or land after sign-out. Applying the resolved subtitle values to the cached profile needs no request, so none of that can happen. * fix(settings): mirror the shown subtitle settings into the cached profile The screen's existing guards already decide which response wins, and a successful write whose re-read failed still shows the written value, so the cache follows the screen instead of each raw snapshot. * fix(settings): cache only confirmed subtitle values Mirroring the whole screen could cache unloaded defaults or a pending edit whose write later failed. Each subtitle setter now stores its own confirmed value, unless a newer edit of that field superseded it, and applyResolved is back to its original form. * fix(downloads): preserve offline track cleanup and preferences Await subtitle capture before removing completed downloads. Cache the newest successful subtitle preference write for each field, including when a later edit fails. Save original-download audio choices with the online catalog's fingerprint identity. AI disclosure: gpt-6.1-sol through the Codex harness in T3 Code. Co-authored-by: Codex * fix(settings): ignore subtitle responses after a newer success Apply the same confirmed-generation guard to the settings screen and offline cache. Verify that a late older response cannot replace the newest confirmed mode on either surface. AI disclosure: gpt-6.1-sol through the Codex harness in T3 Code. Generated-by: Codex (gpt-6.1-sol) * fix(downloads): recover completion reporting after publication A retry can find completed local media after the process stopped before the completion status job was queued. Requeue completion for the stored revision before capturing offline tracks, preserving the finished file and event order. Unknown legacy revisions remain unreported because their bytes cannot prove a current registry revision. Generated-by: Codex (gpt-6.1-sol) in T3 Code * fix(player): omit offline subtitle identity from logs Log a fixed event when automatic subtitle selection applies so diagnostics do not include the local subtitle sidecar URI. Generated-by: Codex (gpt-6.1-sol) in T3 Code --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Codex --- .../13.json | 989 ++++++++++++++++++ .../silo/common/data/db/SiloDatabase.kt | 3 +- .../common/data/db/entity/DownloadEntity.kt | 4 + .../downloads/DownloadSidecarMapping.kt | 5 + .../silo/common/downloads/DownloadStorage.kt | 33 +- .../silo/common/downloads/DownloadWorker.kt | 66 +- .../downloads/OfflineTrackAssetFetcher.kt | 176 ++++ .../silo/common/player/SubtitleManager.kt | 9 + .../common/player/SubtitleMountResolver.kt | 23 +- .../common/player/video/AudioReconcile.kt | 18 + .../data/db/SiloDatabaseMigrationTest.kt | 24 + ...DownloadSidecarOfflineTracksMappingTest.kt | 53 + .../downloads/OfflineTrackAssetFetcherTest.kt | 133 +++ .../SubtitleManagerTrackSelectionTest.kt | 21 + .../video/AudioReconcilePositionalTest.kt | 94 ++ .../silo/android/di/AndroidModule.kt | 1 + .../screens/downloads/DownloadsViewModel.kt | 7 +- .../screens/player/OfflinePlaybackTracks.kt | 93 ++ .../android/ui/screens/player/PlayerScreen.kt | 9 + .../ui/screens/player/PlayerViewModel.kt | 224 +++- .../ui/screens/settings/SettingsViewModel.kt | 38 +- .../player/MobileAudioTrackSelectionTest.kt | 19 + .../player/OfflinePlaybackTracksTest.kt | 117 +++ ...SettingsViewModelOfflinePreferencesTest.kt | 190 ++++ .../silo/model/download/DownloadSidecar.kt | 4 + .../silo/model/download/OfflineTracks.kt | 134 +++ .../silo/model/profile/ActiveProfileStore.kt | 6 + .../silo/model/download/OfflineTracksTest.kt | 146 +++ .../model/profile/ActiveProfileStoreTest.kt | 14 + 29 files changed, 2616 insertions(+), 37 deletions(-) create mode 100644 android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/13.json create mode 100644 android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt create mode 100644 android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/AudioReconcilePositionalTest.kt create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracks.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracksTest.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt create mode 100644 shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt create mode 100644 shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt diff --git a/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/13.json b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/13.json new file mode 100644 index 000000000..5d3dc4bad --- /dev/null +++ b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/13.json @@ -0,0 +1,989 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "86a5233fe50f7b265ded23af8525415e", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, `offlineTracksJson` TEXT, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "offlineTracksJson", + "columnName": "offlineTracksJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '86a5233fe50f7b265ded23af8525415e')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt index f690dc1d8..bff238b76 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt @@ -56,7 +56,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity MembershipProjectionEntity::class, LegacyMembershipQuarantineEntity::class, ], - version = 12, + version = 13, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -69,6 +69,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity AutoMigration(from = 8, to = 9), AutoMigration(from = 10, to = 11), AutoMigration(from = 11, to = 12), + AutoMigration(from = 12, to = 13), ], ) abstract class SiloDatabase : RoomDatabase() { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt index 4ce7c95f4..ea39b119e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt @@ -82,4 +82,8 @@ data class DownloadEntity( /** Registry revision whose bytes this row describes. A local completion * only speaks for this revision; null on rows written before v12. */ val revision: Int? = null, + /** Serialized [org.siloserver.silo.model.download.OfflineTrackInfo]: the + * downloaded file's audio tracks and the local subtitle sidecars. Null for + * downloads completed before offline track data was captured. */ + val offlineTracksJson: String? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt index 77478cbf6..dd859337d 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt @@ -4,6 +4,7 @@ import org.siloserver.silo.common.data.db.entity.DownloadEntity import org.siloserver.silo.model.catalog.VersionChapter import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadSidecar +import org.siloserver.silo.model.download.OfflineTrackInfo import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -58,6 +59,7 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit quality = record.quality, effectiveQuality = record.effectiveQuality, revision = record.revision, + offlineTracksJson = offlineTracks?.let { mappingJson.encodeToString(it) }, ) fun DownloadEntity.toSidecar(): DownloadSidecar = @@ -97,5 +99,8 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = durationSeconds = durationSeconds, chapters = chaptersJson?.let { runCatching { mappingJson.decodeFromString>(it) }.getOrNull() }, resumeValidator = resumeValidator, + offlineTracks = offlineTracksJson?.let { + runCatching { mappingJson.decodeFromString(it) }.getOrNull() + }, updatedAtMs = updatedAtMs, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt index acf317f73..33321250e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt @@ -106,8 +106,10 @@ class DownloadStorage( * directories are left in place; cleanup at higher granularity goes * through [deleteAllForProfile] or [deleteAllForServer]. */ - fun delete(serverId: String, profileId: String, fileId: Int): Boolean = - publicStore.delete(serverId, profileId, fileId, uriString = null) + fun delete(serverId: String, profileId: String, fileId: Int): Boolean { + containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString())?.deleteRecursively() + return publicStore.delete(serverId, profileId, fileId, uriString = null) + } fun completeWrite(uriString: String): String = publicStore.complete(uriString) @@ -133,12 +135,27 @@ class DownloadStorage( /** Wipes every downloaded byte under (serverId, profileId). Used on sign-out * and profile switch. Metadata rows are cleared via [DownloadMetadataStore]. */ - fun deleteAllForProfile(serverId: String, profileId: String): Boolean = - publicStore.deleteAllForProfile(serverId, profileId) + fun deleteAllForProfile(serverId: String, profileId: String): Boolean { + containedSafeChild(offlineAssetsRoot, serverId, profileId)?.deleteRecursively() + return publicStore.deleteAllForProfile(serverId, profileId) + } /** Wipes every downloaded byte under (serverId). Used on server delete / re-bind. */ - fun deleteAllForServer(serverId: String): Boolean = - publicStore.deleteAllForServer(serverId) + fun deleteAllForServer(serverId: String): Boolean { + containedSafeChild(offlineAssetsRoot, serverId)?.deleteRecursively() + return publicStore.deleteAllForServer(serverId) + } + + /** + * Private directory for the offline subtitle sidecars of one download. + * Kept in app-internal storage rather than beside the (public, possibly + * MediaStore-owned) media file: they are only meaningful to this app, and + * a scoped directory is removed with the download by [delete]. + */ + fun offlineSubtitleDirectory(serverId: String, profileId: String, fileId: Int): File? = + containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString(), SUBTITLES_DIR) + + private val offlineAssetsRoot: File get() = File(baseDir, OFFLINE_ASSETS_DIR) /** Sum of bytes across every downloaded file under this storage. */ fun totalBytesUsed(): Long = publicStore.totalBytesUsed() @@ -170,6 +187,10 @@ class DownloadStorage( private fun sanitizeBasename(value: String): String = value.replace(Regex("[\\\\/:*?\"<>|]"), "_") + private companion object { + const val OFFLINE_ASSETS_DIR = "download-assets" + const val SUBTITLES_DIR = "subtitles" + } } data class DownloadTarget( diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt index c909952cd..8c668ecab 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt @@ -92,6 +92,7 @@ class DownloadWorker( ) : CoroutineWorker(appContext, params) { private var transferAuthority: DurableLoginAuthority? = null + private val offlineTrackFetcher = OfflineTrackAssetFetcher(httpClient, storage) private suspend fun requireOwner() { if (authorities != null && (transferAuthority == null || transferAuthority != authorities.snapshotDurableLoginAuthority() || inputData.getString(KEY_DEVICE_ID) != devices?.current()?.id)) throw DownloadOwnerChanged() } @@ -144,8 +145,32 @@ class DownloadWorker( }.onFailure { Log.w(TAG, "setForeground initial failed", it) } var activeUri: String? = null + // Capture can outlive media publication. A stop then preserves the + // completed file and its queued completion report. + var mediaPublished = false + try { var existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() + // A stopped capture resumes from the completed local file, including + // legacy rows whose original transfer revision is unknown. + if (existing != null && existing.record.id == downloadId && + existing.record.statusEnum() == DownloadStatus.Completed && + storage.locateLocalMedia(serverId, profileId, fileId) != null + ) { + mediaPublished = true + requireOwner() + // Publication and status enqueue are separate durable writes. + // Recover a stop between them using the revision of these bytes. + reportStatus( + downloadId, DownloadStatus.Completed, + maxOf(System.currentTimeMillis(), existing.updatedAtMs + 1), + existing.record.revision?.takeIf { it > 0 }, serverId, profileId, + ) + if (existing.offlineTracks == null) { + captureOfflineTracks(downloadId, serverId, profileId, fileId, mediaType) + } + return@withContext Result.success() + } // Old WorkManager jobs have no revision input. Resolve it as their owner // and save it in Room so another attempt does not depend on a UI cache. val revision = inputData.getInt(KEY_REVISION, 0).takeIf { it > 0 } @@ -335,6 +360,7 @@ class DownloadWorker( ) true } + mediaPublished = true Log.i(TAG, "doWork success id=$downloadId bytes=$finalBytes") DiagnosticsDownloadLogger.event("download completed") // Strictly after the `downloading` event: the server ignores an event @@ -355,6 +381,7 @@ class DownloadWorker( } // The v2 file route never marks the entry completed; this report does. reportStatus(downloadId, DownloadStatus.Completed, completedAtMs, revision, serverId, profileId) + captureOfflineTracks(downloadId, serverId, profileId, fileId, mediaType) Result.success(workDataOf(KEY_BYTES_WRITTEN to finalBytes, KEY_TOTAL_BYTES to finalBytes)) } catch (e: DownloadOwnerChanged) { // Preserve the original owner's partial; a new login cannot resume it. @@ -371,7 +398,7 @@ class DownloadWorker( // downloads with a red badge and delete-then-fail them. Log.i(TAG, "doWork cancelled id=$downloadId") DiagnosticsDownloadLogger.event("download cancelled") - withContext(NonCancellable) { + if (!mediaPublished) withContext(NonCancellable) { // WorkManager's notification action cancels this transfer directly. // Its separate status job must stop before we remove the bytes. runCatching { DownloadStatusWorker.cancel(appContext, downloadId) } @@ -450,6 +477,43 @@ class DownloadWorker( }.onFailure { Log.w(TAG, "status report enqueue failed id=$downloadId status=${status.wire}", it) } } + /** + * Fetches the offline manifest's audio tracks and subtitle sidecars for a + * published video download and stores them on its metadata row. Best + * effort: every failure leaves the download playable the legacy way. + */ + private suspend fun captureOfflineTracks( + downloadId: String, + serverId: String, + profileId: String, + fileId: Int, + mediaType: String?, + ) { + if (!OfflineTrackAssetFetcher.appliesTo(mediaType)) return + try { + requireOwner() + val tracks = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { + transferAuthority?.let { managedDownloadAuth(it.scope) } + } ?: return + ownedWrite { + val existing = metadataStore.readSidecar(serverId, profileId, fileId) + ?.takeIf { it.record.id == downloadId } + if (existing != null) { + metadataStore.writeSidecar( + serverId, profileId, + existing.copy(offlineTracks = tracks, updatedAtMs = System.currentTimeMillis()), + ) + } + true + } + Log.i(TAG, "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} subtitles=${tracks.subtitles.size}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "offline track capture skipped id=$downloadId", e) + } + } + /** Permanent failure — clean up local file and let the user retry manually. */ private suspend fun failPermanently( e: Throwable, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt new file mode 100644 index 000000000..d88c7733f --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt @@ -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 + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt index 67d983f6b..1f26a2abc 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt @@ -1712,9 +1712,17 @@ fun isSubtitleSelected(tracks: Tracks, identity: SubtitleIdentity): Boolean { ?.isTrackSelected(selection.trackIndex) == true } +/** + * Every text track in a snapshot that this device can render, in snapshot + * order, described the way subtitle resolution sees it. + */ +fun renderableMountedTextTracks(tracks: Tracks): List = + textTrackCandidates(tracks).filter(TextTrackCandidate::supported).map(TextTrackCandidate::track) + private data class TextTrackCandidate( val selection: SubtitleSelection, val track: MountedSubtitleTrack, + val supported: Boolean, ) private fun textTrackCandidates(tracks: Tracks): List { @@ -1735,6 +1743,7 @@ private fun textTrackCandidates(tracks: Tracks): List { forced = format.selectionFlags and C.SELECTION_FLAG_FORCED != 0, hearingImpaired = format.isHearingImpairedSubtitle(), ), + supported = group.isTrackSupported(trackIndex), ) flatIndex++ } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleMountResolver.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleMountResolver.kt index f3e85534c..60edfdb79 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleMountResolver.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleMountResolver.kt @@ -20,6 +20,14 @@ private const val SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = "silo-subtitle:" fun subtitleArtifactTrackId(serverIndex: Int): String = "$SUBTITLE_ARTIFACT_TRACK_ID_PREFIX$serverIndex" +/** + * True for a Media3 track id Silo authored for a subtitle artifact (a + * [subtitleArtifactTrackId] or a downloaded-subtitle id), including one Media3 + * reports with a merged-source child prefix (`1:silo-subtitle:0`). + */ +fun isSubtitleArtifactTrackId(trackId: String?): Boolean = + trackId?.withoutMergedSourcePrefix().isReservedArtifactTrackId() + /** * True when a mounted Media3 `Format.id` denotes [expected]. * @@ -33,13 +41,14 @@ fun subtitleArtifactTrackId(serverIndex: Int): String = * Only a purely numeric prefix is accepted, so this can never collide with an * authored id that happens to contain a colon. */ -fun trackIdDenotes(actual: String?, expected: String): Boolean { - if (actual == null) return false - if (actual == expected) return true - val separator = actual.indexOf(':') - if (separator <= 0) return false - if (!actual.substring(0, separator).all(Char::isDigit)) return false - return actual.substring(separator + 1) == expected +fun trackIdDenotes(actual: String?, expected: String): Boolean = + actual != null && (actual == expected || actual.withoutMergedSourcePrefix() == expected) + +/** [trackIdDenotes]'s merged-source form: drops a purely numeric `:` prefix. */ +private fun String.withoutMergedSourcePrefix(): String { + val separator = indexOf(':') + if (separator <= 0 || !substring(0, separator).all(Char::isDigit)) return this + return substring(separator + 1) } /** diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt index c0b382df8..37f65f9fc 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt @@ -77,6 +77,11 @@ sealed interface AudioReconcileAction { * @param planAudioOrdinal the catalog ordinal the server says it delivered. * @param requiresMountedIdentity whether a byte-for-byte original-file plan * must prove the selected catalog row against Media3's mounted inventory. + * @param positionalCatalog whether [catalog] lists the mounted file's own audio + * tracks in file order (a downloaded file described by its offline manifest). + * The catalog ordinal then IS the Media3 audio-group ordinal, so selection + * goes by position: a re-encoded track (TrueHD source delivered as AAC) can + * never identity-match the source description, but it sits at the same place. */ fun reconcileDesiredAudioAction( desired: DesiredAudio?, @@ -86,6 +91,7 @@ fun reconcileDesiredAudioAction( selectedOrdinal: Int?, planAudioOrdinal: Int?, requiresMountedIdentity: Boolean = false, + positionalCatalog: Boolean = false, ): AudioReconcileAction { if (desired == null) return AudioReconcileAction.None // An empty or partial snapshot is not evidence of anything. The intent must @@ -102,6 +108,18 @@ fun reconcileDesiredAudioAction( val wanted = catalog.getOrNull(desired.catalogOrdinal) ?: return AudioReconcileAction.None + // Only trusted while the inventories agree in size: a snapshot that shows a + // different number of audio groups is not the file the catalog describes, + // and falls back to identity matching below. + if (positionalCatalog && mounted.size == catalog.size) { + val target = mounted[desired.catalogOrdinal] + return if (selectedOrdinal == target.ordinal) { + AudioReconcileAction.Confirm + } else { + AudioReconcileAction.Apply(target.ordinal) + } + } + // Resolved ONCE against the whole snapshot. Matching a one-element list // asks a different question: the matcher stops as soon as one candidate // remains, so a main mix and its commentary — same language, same codec — diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt index 6f4ffcdca..db0e94ae1 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt @@ -120,6 +120,30 @@ class SiloDatabaseMigrationTest { } } + @Test + fun migration12To13KeepsDownloadRevisionsAndAddsNullableOfflineTracks() { + val name = "migration-12-to-13" + migrationHelper.createDatabase(name, 12).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, mediaType, " + + "status, kind, fileSize, bytesSent, createdAt, updatedAtMs, revision) VALUES " + + "('s', 'p', 42, 'dl_1', 'mv_1', 'Example', 'movie', 'completed', 'queued', 1024, 1024, " + + "'2026-09-29T00:00:00Z', 123, 7)", + ) + } + migrationHelper.runMigrationsAndValidate(name, 13, true).use { database -> + database.query("SELECT recordId, status, bytesSent, revision, offlineTracksJson FROM downloads").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("dl_1", cursor.getString(0)) + assertEquals("completed", cursor.getString(1)) + assertEquals(1024L, cursor.getLong(2)) + assertEquals(7, cursor.getInt(3)) + assertNull(cursor.getString(4)) + assertEquals(false, cursor.moveToNext()) + } + } + } + private companion object { const val DATABASE_NAME = "migration-7-to-8" } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt new file mode 100644 index 000000000..44547a70c --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt @@ -0,0 +1,53 @@ +package org.siloserver.silo.common.downloads + +import org.siloserver.silo.model.catalog.AudioTrack +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadSidecar +import org.siloserver.silo.model.download.OfflineSubtitleFile +import org.siloserver.silo.model.download.OfflineTrackInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DownloadSidecarOfflineTracksMappingTest { + + private val sidecar = DownloadSidecar( + record = DownloadRecord( + id = "dl_1", + contentId = "mv_1", + mediaFileId = 42, + kind = "queued", + status = "completed", + createdAt = "2026-09-29T00:00:00Z", + ), + title = "Example", + updatedAtMs = 1L, + ) + + @Test + fun offlineTracksSurviveTheRoomRow() { + val tracks = OfflineTrackInfo( + audioTracks = listOf( + AudioTrack(index = 0, language = "eng", codec = "aac", channels = 2, isDefault = true), + AudioTrack(index = 1, language = "jpn", codec = "aac", channels = 2), + ), + selectedAudioTrackIndex = 1, + subtitles = listOf( + OfflineSubtitleFile(path = "/data/subs/0.ass", format = "ass", language = "eng", forced = true), + ), + ) + + val restored = sidecar.copy(offlineTracks = tracks).toEntity("srv", "prof").toSidecar() + + assertEquals(tracks, restored.offlineTracks) + } + + @Test + fun rowsWithoutOfflineTracksStillLoad() { + val entity = sidecar.toEntity("srv", "prof") + + assertNull(entity.offlineTracksJson) + assertNull(entity.toSidecar().offlineTracks) + assertNull(entity.copy(offlineTracksJson = "{not json").toSidecar().offlineTracks) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt new file mode 100644 index 000000000..a41eb6c8a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt @@ -0,0 +1,133 @@ +package org.siloserver.silo.common.downloads + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.defaultRequest +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflineTrackAssetFetcherTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val manifest = """ + { + "download_id": "dl_1", + "selected_audio_track_index": 1, + "audio_tracks": [ + {"index": 0, "language": "eng", "codec": "aac", "channels": 2, "default": true}, + {"index": 1, "language": "jpn", "codec": "aac", "channels": 2} + ], + "subtitles": [ + {"language": "eng", "format": "ass", "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:2"}, + {"language": "fre", "format": "srt", "external": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0"}, + {"language": "eng", "format": "sup", "forced": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:4"}, + {"language": "ger", "format": "sub", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:1"}, + {"language": "spa", "format": "srt", "fetch_url": "https://elsewhere.example/sub.srt"} + ] + } + """.trimIndent() + + private val requested = mutableListOf() + + private fun client( + manifestStatus: HttpStatusCode = HttpStatusCode.OK, + transientManifestFailures: Int = 0, + ) = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + requested += path + when { + path.endsWith("/manifest") && requested.count { it.endsWith("/manifest") } <= transientManifestFailures -> + respond("", HttpStatusCode.ServiceUnavailable) + path.endsWith("/manifest") -> respond(manifest, manifestStatus) + path.endsWith("/subtitles/embedded:2") -> respond("[Script Info]\nTitle: x\n") + // The server failed to read the external sidecar. + path.endsWith("/subtitles/external:0") -> respond("", HttpStatusCode.NotFound) + path.endsWith("/subtitles/embedded:4") -> respond(byteArrayOf(0x50, 0x47, 0x00, 0x01)) + else -> respond("", HttpStatusCode.InternalServerError) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://silo.example/") } + } + + @Test + fun capturesAudioTracksAndSavesEveryFetchableSidecar() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val info = assertNotNull( + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + + assertEquals(listOf("eng", "jpn"), info.audioTracks.map { it.language }) + assertEquals(1, info.defaultAudioPosition()) + // The failed SRT, the unmountable .sub and the foreign URL are skipped + // without failing the rest. + assertEquals(listOf("ass", "pgs"), info.subtitles.map { it.format }) + assertEquals(listOf(false, true), info.subtitles.map { it.forced }) + val ass = File(info.subtitles[0].path) + val pgs = File(info.subtitles[1].path) + assertTrue(ass.isFile && ass.name.endsWith(".ass")) + assertTrue(pgs.isFile && pgs.name.endsWith(".sup")) + assertEquals(storage.offlineSubtitleDirectory("srv", "prof", 42), ass.parentFile) + assertFalse(requested.any { it.contains("sub.srt") }) + assertTrue(ass.parentFile!!.listFiles()!!.none { it.name.endsWith(".part") }) + } + + @Test + fun deletingTheDownloadRemovesItsSidecars() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val info = assertNotNull( + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + + storage.delete("srv", "prof", 42) + + assertTrue(info.subtitles.none { File(it.path).exists() }) + } + + @Test + fun aMissingManifestCapturesNothing() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + assertNull( + OfflineTrackAssetFetcher(client(HttpStatusCode.NotFound), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + assertEquals(listOf("/api/v2/downloads/dl_1/manifest"), requested) + } + + @Test + fun aTransientManifestFailureIsRetried() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val info = assertNotNull( + OfflineTrackAssetFetcher(client(transientManifestFailures = 2), storage, manifestRetryDelayMs = 0) + .fetch("dl_1", "srv", "prof", 42) {}, + ) + + assertEquals(3, requested.count { it.endsWith("/manifest") }) + assertEquals(2, info.audioTracks.size) + } + + @Test + fun onlyVideoDownloadsCaptureTracks() { + assertTrue(OfflineTrackAssetFetcher.appliesTo("movie")) + assertTrue(OfflineTrackAssetFetcher.appliesTo("tv")) + assertFalse(OfflineTrackAssetFetcher.appliesTo("audiobook")) + assertFalse(OfflineTrackAssetFetcher.appliesTo("ebook")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerTrackSelectionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerTrackSelectionTest.kt index e35cae9f9..2af8161e6 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerTrackSelectionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerTrackSelectionTest.kt @@ -609,6 +609,27 @@ class SubtitleManagerTrackSelectionTest { } } + @Test + fun renderableTextTracksDropUnsupportedTracksAndReportTheCueSourceFormat() { + fun group(format: Format, support: Int) = + Tracks.Group(TrackGroup(format), false, intArrayOf(support), booleanArrayOf(false)) + val tracks = Tracks( + listOf( + group( + subtitle("English", "en", MimeTypes.APPLICATION_MEDIA3_CUES, MimeTypes.TEXT_SSA, id = "0:3", forced = true), + C.FORMAT_HANDLED, + ), + group(subtitle(null, "it", MimeTypes.APPLICATION_TX3G, id = "0:4"), C.FORMAT_UNSUPPORTED_TYPE), + ), + ) + + val mounted = renderableMountedTextTracks(tracks).single() + + assertEquals("0:3", mounted.trackId) + assertEquals(MimeTypes.TEXT_SSA, mounted.codec) + assertEquals(true, mounted.forced) + } + private fun subtitle( label: String?, language: String?, diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/AudioReconcilePositionalTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/AudioReconcilePositionalTest.kt new file mode 100644 index 000000000..f948a907f --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/AudioReconcilePositionalTest.kt @@ -0,0 +1,94 @@ +package org.siloserver.silo.common.player.video + +import org.siloserver.silo.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Downloaded files described by their offline manifest: the catalog lists the + * file's own audio tracks in file order, so selection goes by position. + */ +class AudioReconcilePositionalTest { + + /** Two English stereo AAC tracks that only a title could tell apart. */ + private val catalog = listOf( + AudioTrack(index = 0, codec = "aac", channels = 2, language = "eng", title = "Main"), + AudioTrack(index = 1, codec = "aac", channels = 2, language = "jpn"), + AudioTrack(index = 2, codec = "aac", channels = 2, language = "eng", title = "Commentary"), + ) + + /** Media3 reports no labels for the MP4's tracks. */ + private val mounted = listOf( + MountedAudioTrack(0, "eng", "audio/mp4a-latm", 2), + MountedAudioTrack(1, "jpn", "audio/mp4a-latm", 2), + MountedAudioTrack(2, "eng", "audio/mp4a-latm", 2), + ) + + private fun desire(ordinal: Int) = DesiredAudio( + generation = 1L, + catalogOrdinal = ordinal, + explicit = true, + fileId = 7, + ) + + private fun reconcile( + ordinal: Int, + selectedOrdinal: Int?, + mountedTracks: List = mounted, + catalogTracks: List = catalog, + positional: Boolean = true, + ) = reconcileDesiredAudioAction( + desired = desire(ordinal), + activeFileId = 7, + catalog = catalogTracks, + mounted = mountedTracks, + selectedOrdinal = selectedOrdinal, + planAudioOrdinal = null, + positionalCatalog = positional, + ) + + @Test + fun selectsTheMountedGroupAtTheSamePosition() { + assertEquals(AudioReconcileAction.Apply(2), reconcile(ordinal = 2, selectedOrdinal = 0)) + assertEquals(AudioReconcileAction.Confirm, reconcile(ordinal = 2, selectedOrdinal = 2)) + } + + @Test + fun identityMatchingCannotTellTheSameLanguageTracksApart() { + // Without the positional mode the untitled mount is ambiguous, so the + // pick silently goes nowhere: the failure this mode exists for. + assertEquals( + AudioReconcileAction.None, + reconcile(ordinal = 2, selectedOrdinal = 0, positional = false), + ) + } + + @Test + fun reEncodedTracksAreSelectedEvenWhenTheCatalogNamesTheSourceCodec() { + val sourceDescribed = listOf( + AudioTrack(index = 0, codec = "truehd", channels = 8, language = "eng"), + AudioTrack(index = 1, codec = "ac3", channels = 6, language = "fre"), + ) + val remux = listOf( + MountedAudioTrack(0, "eng", "audio/mp4a-latm", 2), + MountedAudioTrack(1, "fre", "audio/ac3", 6), + ) + + assertEquals( + AudioReconcileAction.Apply(0), + reconcile(ordinal = 0, selectedOrdinal = 1, mountedTracks = remux, catalogTracks = sourceDescribed), + ) + } + + @Test + fun aSnapshotOfADifferentSizeFallsBackToIdentityMatching() { + val legacySingleTrack = listOf(MountedAudioTrack(0, "jpn", "audio/mp4a-latm", 2)) + + // Identity matching finds the Japanese track even though the file does + // not have the three tracks the catalog lists. + assertEquals( + AudioReconcileAction.Confirm, + reconcile(ordinal = 1, selectedOrdinal = 0, mountedTracks = legacySingleTrack), + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 8df2f18c3..ff509080c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -386,6 +386,7 @@ val androidModule = module { sectionRepository = get(), castPlaybackPreparer = get(), seekIntervalStore = get(), + activeProfileStore = get(), ) } viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt index d10c875f3..13a724a49 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt @@ -526,10 +526,9 @@ class DownloadsViewModel( // write a DURABLE tombstone (so the record can't resurrect as a ghost on // the next online refresh — written BEFORE byte deletion so a crash can't // lose the server-delete intent), then drop the bytes + metadata. - val status = record?.statusEnum() ?: sidecar?.record?.statusEnum() - if (status == DownloadStatus.Queued || status == DownloadStatus.Downloading) { - downloadEnqueuer.cancel(id) - } + // Completed media can still be capturing subtitle sidecars. Wait + // for that worker too, before removing files or its metadata row. + downloadEnqueuer.cancel(id) // Drop the in-memory sidecar maps BEFORE the tombstone: enqueueDurableDelete // emits on repository.records and the collector rebuilds sections from // metadataByRecordId — if the entry were still present the deleted row would diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracks.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracks.kt new file mode 100644 index 000000000..94ee0b6bf --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracks.kt @@ -0,0 +1,93 @@ +package org.siloserver.silo.android.ui.screens.player + +import org.siloserver.silo.common.player.MountedSubtitleTrack +import org.siloserver.silo.common.player.isSubtitleArtifactTrackId +import org.siloserver.silo.common.player.subtitleArtifactTrackId +import org.siloserver.silo.model.download.OfflineSubtitleFile +import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.playback.canonicalSubtitleCodecFamily +import org.siloserver.silo.playback.isBitmapSubtitleCodecFamily +import org.siloserver.silo.playback.isClientMountableBitmapCodecFamily + +/** + * Menu indexes for text tracks found inside a downloaded file. Kept clear of + * the sidecar rows' indexes (0 until sidecar count), which also key the + * sidecars' Media3 track ids (`silo-subtitle:`). + */ +internal const val OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE = 10_000 + +/** Subtitle preferences applied to offline playback, as online playback resolves them. */ +internal data class OfflineSubtitlePreferences( + val preferredLanguage: String?, + val mode: String?, + val showForced: Boolean, +) + +/** + * Menu rows for the subtitle sidecars saved with a download. Each row's + * [PlayerSubtitleInfo.index] is its position here, which the mount turns into + * the sidecar's Media3 track id, so selection resolves by that exact id. + * Files that have gone missing are left out. + */ +internal fun offlineSidecarSubtitleRows( + files: List, + fileExists: (String) -> Boolean = { java.io.File(it).isFile }, +): List = + files.filter { fileExists(it.path) }.mapIndexed { index, file -> + PlayerSubtitleInfo( + index = index, + language = file.language, + codec = file.format, + label = file.title ?: if (file.hearingImpaired) "SDH" else null, + source = "external", + forced = file.forced, + url = "file://" + java.io.File(file.path).toURI().rawPath, + // The id the mount gives this sidecar; also keeps two otherwise + // identical sidecars distinct when the menu resolves a selection. + mediaTrackId = subtitleArtifactTrackId(index), + ) + } + +/** + * Menu rows for the text tracks inside the downloaded file itself (MP4 timed + * text in a prepared download; any embedded track of an original download). + * + * The file's own default flag is deliberately not carried: a prepared MP4 + * marks its first subtitle track default whether or not it should show, so + * only the forced flag and the viewer's preferences may turn one on. + * + * [tracks] are the renderable ones (renderableMountedTextTracks). Merged + * sidecars and bitmap families the player cannot mount are left out. + */ +internal fun localEmbeddedSubtitleRows(tracks: List): List = + tracks + .filter { track -> + val family = canonicalSubtitleCodecFamily(track.codec) + track.trackId != null && + !isSubtitleArtifactTrackId(track.trackId) && + (!isBitmapSubtitleCodecFamily(family) || isClientMountableBitmapCodecFamily(family)) + } + .mapIndexed { ordinal, track -> + PlayerSubtitleInfo( + index = OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE + ordinal, + language = track.language, + codec = track.codec, + label = track.label, + source = "embedded", + forced = track.forced, + url = "", + mediaTrackId = track.trackId, + ) + } + +/** A row [localEmbeddedSubtitleRows] produced. */ +internal fun PlayerSubtitleInfo.isLocalEmbeddedSubtitleRow(): Boolean = + source == "embedded" && url.isBlank() && index >= OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE + +/** + * A row that exists only in offline playback: a track inside the downloaded + * file or a sidecar saved on the device. Its index is a local menu position, + * never a server subtitle index. + */ +internal fun PlayerSubtitleInfo.isOfflineLocalSubtitleRow(): Boolean = + isLocalEmbeddedSubtitleRow() || url.startsWith("file://") diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt index 5a0cebe37..ba382e736 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt @@ -98,6 +98,7 @@ import org.siloserver.silo.common.player.video.PlaybackStartupStallDetector import org.siloserver.silo.common.player.video.PlaybackRuntimeCorrectionMetrics import org.siloserver.silo.common.player.video.PostResumeVideoStallDetector import org.siloserver.silo.common.player.isSubtitleSelected +import org.siloserver.silo.common.player.renderableMountedTextTracks import org.siloserver.silo.common.player.video.VideoPlayerTrackEntry import org.siloserver.silo.model.playback.PlaybackExecutionPlan import org.siloserver.silo.model.playback.PlayerSubtitleInfo @@ -1039,6 +1040,14 @@ fun PlayerScreen( val backend = videoBackend ?: return if (!viewModel.isCurrentSubtitleMount(mount)) return val currentTracks = backend.player.currentTracks + // A downloaded file's own text tracks are only known now; + // this completes its subtitle menu and runs auto-selection + // before the selection below reads the live state. + viewModel.onLocalMediaTracksChanged( + mediaId = backend.player.currentMediaItem?.mediaId, + textTracks = renderableMountedTextTracks(currentTracks), + hasTracks = !currentTracks.isEmpty, + ) val liveState = viewModel.uiState.value val pendingIdentity = liveState.localSubtitleMountIdentity val targetIdentity = pendingIdentity ?: liveState.committedSubtitleIdentity diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 87af85cde..eff41a530 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -85,6 +85,7 @@ import org.siloserver.silo.model.subtitles.SubtitleSearchRequest import org.siloserver.silo.model.subtitles.SubtitleTranslateRequest import org.siloserver.silo.network.ApiResult import org.siloserver.silo.common.player.AutoPlayGuard +import org.siloserver.silo.common.player.MountedSubtitleTrack import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.errorMessage import org.siloserver.silo.playback.audioTrackFingerprint @@ -135,6 +136,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.siloserver.silo.playback.orNullIfBlank import java.util.concurrent.atomic.AtomicBoolean /** @@ -157,7 +159,11 @@ internal fun selectedServerSubtitleTrackIndex( subtitleTracks: List, ): Int? = when (selectedOrdinal) { -1 -> -1 - else -> subtitleTracks.getOrNull(selectedOrdinal)?.index + // A downloaded file's own tracks and saved sidecars carry local menu + // indexes that name no server subtitle; let the server apply preferences. + else -> subtitleTracks.getOrNull(selectedOrdinal) + ?.takeUnless { it.isOfflineLocalSubtitleRow() } + ?.index } data class PlaybackClock( @@ -226,8 +232,12 @@ internal fun PlayerViewModel.PlayerUiState.withPlaybackClock(clock: PlaybackCloc internal fun mobileAudioTrackPersistenceUpdate( committedAudioTrackIndex: Int?, audioTracks: List, + originalDownload: Boolean = false, ): TrackSelectionFingerprintUpdate = committedAudioTrackIndex ?.let(audioTracks::getOrNull) + // Offline manifests number audio rows by position. The online catalog + // omits this field, so its source-track fingerprints use the default 0. + ?.let { if (originalDownload) it.copy(index = 0) else it } ?.let(::audioTrackFingerprint) ?.let(TrackSelectionFingerprintUpdate::Set) ?: TrackSelectionFingerprintUpdate.Preserve @@ -284,6 +294,9 @@ class PlayerViewModel( // Profile-wide seek intervals (settings revision 9). Optional so unit // tests that construct the VM directly keep the legacy fixed intervals. private val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore? = null, + // Cached active profile; offline playback reads subtitle preferences from + // it instead of waiting on the server. Optional for the same reason. + private val activeProfileStore: org.siloserver.silo.model.profile.ActiveProfileStore? = null, ) : ViewModel() { // Last load request, replayed by the "Can't reach server" Retry / Try Anyway. @@ -462,6 +475,12 @@ class PlayerViewModel( * current position. */ val subtitleRefreshNonce: Int = 0, + /** + * True when [audioTracks] come from a download's offline manifest and + * list the local file's own audio tracks in file order, so an audio + * choice selects the Media3 audio group at the same position. + */ + val offlineAudioByPosition: Boolean = false, // Live player statistics for phone diagnostics. Populates field-by-field // as PlaybackAnalyticsListener emits decoder, format, bandwidth, and // dropped-frame events. @@ -973,8 +992,10 @@ class PlayerViewModel( upNextVideoEnded = it.upNextVideoEnded && preservesNextUp, upNextCountdownSeconds = null, stats = PlayerStatsSnapshot(), + offlineAudioByPosition = false, ) } + localSubtitleState = null } private var browseLibraryId: Int? = null @@ -1444,6 +1465,7 @@ class PlayerViewModel( subtitleRefreshNonce = 0, preferredAudioLanguage = playbackState.preferredAudioLanguage, preferredTextLanguage = playbackState.preferredTextLanguage, + offlineAudioByPosition = false, ) } @@ -3141,7 +3163,10 @@ class PlayerViewModel( qualityPreference = currentMobileQualityPreference(), subtitleTracks = state.subtitleTracks, audioTracks = state.audioTracks, - writeScope = finalPositionScope, + // A download's subtitle rows describe the local file, not the + // server's inventory. Saved as the item's preference they would not + // resolve online and would suppress auto-selection there. + writeScope = finalPositionScope.takeUnless { state.isLocalFilePlayback() }, ) private fun currentMobileQualityPreference(): String? = @@ -3320,6 +3345,8 @@ class PlayerViewModel( fun onSelectSubtitle(index: Int) { val state = _uiState.value if (index != -1 && index !in state.subtitleTracks.indices) return + // The viewer chose: a later local track discovery must not auto-select over it. + localSubtitleState = localSubtitleState?.copy(autoSelectionPending = false) val identity = state.subtitleTracks .getOrNull(index) ?.let(::mobileSubtitleIdentity) @@ -3371,6 +3398,10 @@ class PlayerViewModel( ) } setDesiredAudio(serverIndex, explicit = userInitiated) + // A downloaded file is switched on the player only: there is no + // server session to replan, and the desired-audio reconcile above + // already applies the track when the file carries it. + if (state.isLocalFilePlayback()) return // Already in the mounted stream: switch it on the player instead of // rebuilding the session to deliver audio already being received. A // replan is only needed when the track is genuinely absent. @@ -3443,6 +3474,7 @@ class PlayerViewModel( selectedOrdinal = selectedOrdinal, planAudioOrdinal = state.playbackPlan?.selectedTracks?.audioIndex, requiresMountedIdentity = requiresMountedIdentity, + positionalCatalog = state.offlineAudioByPosition, ) ) { AudioReconcileAction.None -> armOriginalAudioSelectionVerification( @@ -3484,7 +3516,9 @@ class PlayerViewModel( localAudioAttemptsFor(desired.generation) >= MAX_LOCAL_AUDIO_ATTEMPTS ) { _pendingLocalAudioSelection.value = null - replanForDesiredAudio(desired) + // A downloaded file has no server plan to change; the + // player's own tracks are all there is. + if (!state.isLocalFilePlayback()) replanForDesiredAudio(desired) return } localAudioAttempt += 1 @@ -3600,8 +3634,11 @@ class PlayerViewModel( private fun persistDesiredAudio(catalogOrdinal: Int) { val state = _uiState.value + // A prepared download's rows describe its own re-encoded tracks, whose + // fingerprints would not match the source's tracks online. + if (state.offlineAudioByPosition) return val context = mobileSubtitleContext(state) - val scope = context.writeScope ?: return + val scope = finalPositionScope ?: return viewModelScope.launch { runCatching { userItemStatePort.recordTrackSelection( @@ -3611,6 +3648,7 @@ class PlayerViewModel( audioUpdate = mobileAudioTrackPersistenceUpdate( committedAudioTrackIndex = catalogOrdinal, audioTracks = context.audioTracks, + originalDownload = state.isLocalFilePlayback(), ), // Untouched: this path changed audio only. subtitleUpdate = TrackSelectionFingerprintUpdate.Preserve, @@ -4613,6 +4651,30 @@ class PlayerViewModel( ?: watchDetail?.posterUrl?.takeIf { url -> url.isNotBlank() } ?: sidecar.posterUrl?.takeIf { url -> url.isNotBlank() } + // Downloads that captured their offline manifest describe the local + // file's own audio tracks (positions in the file) and carry saved + // subtitle sidecars. Older downloads keep the catalog audio rows and + // have no sidecars. + val offlineTracks = sidecar.offlineTracks + val offlineAudioTracks = offlineTracks?.audioTracks.orEmpty() + val offlineAudioByPosition = offlineTracks?.audioByPosition == true && offlineAudioTracks.isNotEmpty() + val localAudioTracks = offlineAudioTracks.ifEmpty { versions[selectedIndex].audioTracks.orEmpty() } + // The selected version carries the same rows: cast, replans and the + // subtitle context read audio indexes from it. + val localVersions = if (offlineAudioTracks.isNotEmpty()) { + versions.mapIndexed { index, version -> + if (index == selectedIndex) version.copy(audioTracks = offlineAudioTracks) else version + } + } else { + versions + } + val localSelectedAudio = offlineTracks?.defaultAudioPosition() ?: 0 + val sidecarSubtitles = withContext(Dispatchers.IO) { + offlineSidecarSubtitleRows(offlineTracks?.subtitles.orEmpty()) + } + val subtitlePreferences = offlineSubtitlePreferences(watchDetail) + if (!ownsLoad(loadOwner)) return false + val published = loadOwners.runIfOwned(loadOwner) { val mountGeneration = expectNextMediaMount() val preservesNextUp = nextUpTransitionGate.expectMount( @@ -4650,12 +4712,20 @@ class PlayerViewModel( isPlaying = true, isPaused = false, isBuffering = false, - versions = versions, + versions = localVersions, selectedVersionIndex = selectedIndex, - audioTracks = versions[selectedIndex].audioTracks ?: emptyList(), - subtitleTracks = emptyList(), // sidecars are remote in v1 - selectedAudioIndex = 0, + audioTracks = localAudioTracks, + // Saved sidecars mount with the file. Text tracks inside the + // file join the list once Media3 reports them + // (onLocalMediaTracksChanged). + subtitleTracks = sidecarSubtitles, + selectedAudioIndex = localSelectedAudio, selectedSubtitleIndex = -1, + committedSubtitleIdentity = SubtitleIdentity.Off, + pendingSubtitleIdentity = null, + localSubtitleMountIdentity = null, + subtitleApplying = false, + offlineAudioByPosition = offlineAudioByPosition, intro = watchDetail?.intro, credits = watchDetail?.credits, recap = watchDetail?.recap, @@ -4674,17 +4744,28 @@ class PlayerViewModel( subtitleRefreshNonce = 0, ) } + localSubtitleState = LocalSubtitleState( + contentId = contentId, + mountGeneration = mountGeneration, + preferences = subtitlePreferences, + ) + mobileSubtitleTransactions.resetContent( + context = mobileSubtitleContext(_uiState.value), + committedIdentity = SubtitleIdentity.Off, + ) Log.i( TAG, - "tryLocalPlayback: serving ${media.displayName} (${media.sizeBytes}B) for content=$contentId (sidecar id=${sidecar.record.id})", + "tryLocalPlayback: serving ${media.displayName} (${media.sizeBytes}B) for content=$contentId " + + "(sidecar id=${sidecar.record.id}) offlineAudio=${offlineAudioTracks.size} " + + "sidecarSubtitles=${sidecarSubtitles.size}", ) } - // Downloaded playback publishes the catalog and hardcodes ordinal 0, but - // Media3 still picks its own default from the file's tracks -- so the - // intent has to exist here too or a multi-audio download cannot be - // corrected. - if (_uiState.value.audioTracks.isNotEmpty()) setDesiredAudio(0, explicit = false) + // Media3 picks its own default from the file's tracks, so the intent + // has to exist here too or a multi-audio download cannot be corrected. + // With manifest data the default is the manifest's pick (the viewer's + // preferred language when the file has it); legacy downloads keep 0. + if (_uiState.value.audioTracks.isNotEmpty()) setDesiredAudio(localSelectedAudio, explicit = false) return published } @@ -4721,6 +4802,111 @@ class PlayerViewModel( super.onCleared() } + // ---- Local (downloaded) subtitle discovery --------------------------------- + + /** + * Subtitle state of the downloaded file being played: the preferences to + * auto-select with, the file's own text tracks last listed, and whether auto-selection still + * has to run. Text tracks inside the file are only known once Media3 has + * parsed it, so the menu is completed, and auto-selection runs, from the + * first track snapshot of this mount. + */ + private data class LocalSubtitleState( + val contentId: String, + val mountGeneration: Long, + val preferences: OfflineSubtitlePreferences, + val embeddedTracks: List? = null, + val autoSelectionPending: Boolean = true, + ) + + private var localSubtitleState: LocalSubtitleState? = null + + /** + * Called by PlayerScreen with every Media3 track snapshot of the current + * mount. Only acts for a downloaded file this ViewModel mounted: adds the + * file's own text tracks to the subtitle menu and runs the one-time + * preference-based subtitle auto-selection. + */ + internal fun onLocalMediaTracksChanged( + mediaId: String?, + textTracks: List, + hasTracks: Boolean, + ) { + val local = localSubtitleState ?: return + val state = _uiState.value + if (!state.isLocalFilePlayback()) return + if (state.contentId != local.contentId || state.mediaMountGeneration != local.mountGeneration) return + if (mediaId != null && mediaId != local.contentId) return + // An empty snapshot is the gap between mounts, not a file without tracks. + if (!hasTracks) return + + var current = local + if (current.embeddedTracks != textTracks) { + val embedded = localEmbeddedSubtitleRows(textTracks) + _uiState.update { + // Replace only the rows for the file's own tracks; the saved + // sidecars (and anything else already listed) stay as they are. + val rows = embedded + it.subtitleTracks.filterNot(PlayerSubtitleInfo::isLocalEmbeddedSubtitleRow) + it.copy( + subtitleTracks = rows, + selectedSubtitleIndex = resolveMobileSubtitleOrdinal(it.committedSubtitleIdentity, rows) ?: -1, + ) + } + current = current.copy(embeddedTracks = textTracks) + localSubtitleState = current + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(_uiState.value)) + } + if (!current.autoSelectionPending) return + localSubtitleState = current.copy(autoSelectionPending = false) + + val published = _uiState.value + val selection = resolveMobileAutoSubtitleSelection( + audioTracks = published.audioTracks, + selectedAudioIndex = published.selectedAudioIndex, + subtitles = published.subtitleTracks, + preferredLanguage = current.preferences.preferredLanguage, + subtitleMode = current.preferences.mode, + showForcedSubtitles = current.preferences.showForced, + ) + val identity = when (selection) { + is MobileSubtitleAutoSelection.Select -> + published.subtitleTracks.getOrNull(selection.ordinal)?.let(::mobileSubtitleIdentity) + MobileSubtitleAutoSelection.Disable, + MobileSubtitleAutoSelection.NoChange, + -> null + } ?: return + if (identity == published.committedSubtitleIdentity) return + Log.i(TAG, "local subtitle auto-selection applied") + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(published)) + mobileSubtitleTransactions.select(identity) + } + + /** + * Subtitle preferences for offline playback, resolved the way the online + * starter does: the server's effective values for this item when the + * detail could be read, then the cached active profile, then the + * defaults. Nothing here waits on the server, so an unreachable one cannot + * delay the local file. + */ + private suspend fun offlineSubtitlePreferences( + watchDetail: org.siloserver.silo.model.catalog.WatchDetail?, + ): OfflineSubtitlePreferences { + val language = watchDetail?.effectiveSubtitleLanguage.orNullIfBlank() + val mode = watchDetail?.effectiveSubtitleMode.orNullIfBlank() + val forced = watchDetail?.effectiveShowForcedSubtitles + val profile = if (language == null || mode == null || forced == null) { + val activeId = profileRepository.getActiveProfileId() + activeProfileStore?.activeProfile?.value?.takeIf { it.id == activeId } + } else { + null + } + return OfflineSubtitlePreferences( + preferredLanguage = language ?: profile?.subtitleLanguage.orNullIfBlank(), + mode = mode ?: profile?.subtitleMode.orNullIfBlank(), + showForced = forced ?: profile?.showForcedSubtitles ?: true, + ) + } + private suspend fun resolveDownloadScope(): Pair { val serverId = serverRegistry.activeServerId.value ?: DownloadEnqueuer.DEFAULT_SERVER_ID val profileId = profileRepository.getActiveProfileId() ?: DownloadEnqueuer.DEFAULT_PROFILE_ID @@ -4738,6 +4924,16 @@ private const val PLAYBACK_PAUSE_GRACE_MS = 1_500L /** Snapshots to let a local audio switch take before asking the server. */ private const val MAX_LOCAL_AUDIO_ATTEMPTS = 3 +/** + * A downloaded file mounted by the offline-first path: a local URI with no + * server session or playback plan behind it. + */ +internal fun PlayerViewModel.PlayerUiState.isLocalFilePlayback(): Boolean { + val url = streamUrl ?: return false + return sessionId == null && playbackPlan == null && + (url.startsWith("file://") || url.startsWith("content://")) +} + internal fun authoritativePlaybackSubtitleOrdinal( serverIndex: Int?, playbackTracks: List, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt index 7156f87a0..57b1212e4 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt @@ -143,6 +143,13 @@ class SettingsViewModel( private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var subtitleLanguageEditGeneration = 0L + private var subtitleModeEditGeneration = 0L + private var forcedSubtitlesEditGeneration = 0L + private var subtitleLanguageConfirmedGeneration = 0L + private var subtitleModeConfirmedGeneration = 0L + private var forcedSubtitlesConfirmedGeneration = 0L + /** Profile-wide video and audiobook skip intervals (settings revision 9). */ val seekIntervals = SeekIntervalSettingsModel(seekIntervalStore, audiobookSettingsStore, viewModelScope) @@ -557,8 +564,14 @@ class SettingsViewModel( } } + // The subtitle setters below also store each write's confirmed value in + // the cached profile, which offline playback reads for its subtitle + // preferences. Only a newer successful write to the same field supersedes + // a confirmed value; pending or failed edits leave that value available. + /** [language] is a BCP 47 tag, or "" for off. */ fun setSubtitleLanguage(language: String) { + val editGeneration = ++subtitleLanguageEditGeneration val previous = _uiState.value.subtitleLanguage _uiState.update { it.copy(subtitleLanguage = language) } viewModelScope.launch { @@ -568,12 +581,18 @@ class SettingsViewModel( if (it.subtitleLanguage == language) it.copy(subtitleLanguage = previous) else it } } else { - applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + if (editGeneration > subtitleLanguageConfirmedGeneration) { + applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + subtitleLanguageConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.subtitleLanguage ?: language + activeProfileStore.update { it.copy(subtitleLanguage = confirmed) } + } } } } fun setSubtitleMode(mode: SubtitleMode) { + val editGeneration = ++subtitleModeEditGeneration val previous = _uiState.value.subtitleMode _uiState.update { it.copy(subtitleMode = mode) } viewModelScope.launch { @@ -583,12 +602,18 @@ class SettingsViewModel( if (it.subtitleMode == mode) it.copy(subtitleMode = previous) else it } } else { - applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + if (editGeneration > subtitleModeConfirmedGeneration) { + applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + subtitleModeConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.subtitleMode ?: mode.wire + activeProfileStore.update { it.copy(subtitleMode = confirmed) } + } } } } fun setShowForcedSubtitles(enabled: Boolean) { + val editGeneration = ++forcedSubtitlesEditGeneration val previous = _uiState.value.showForcedSubtitles _uiState.update { it.copy(showForcedSubtitles = enabled) } viewModelScope.launch { @@ -598,8 +623,13 @@ class SettingsViewModel( if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it } } else { - applyResolved(result.snapshot, edited = enabled.toString()) { - it.showForcedSubtitles.toString() + if (editGeneration > forcedSubtitlesConfirmedGeneration) { + applyResolved(result.snapshot, edited = enabled.toString()) { + it.showForcedSubtitles.toString() + } + forcedSubtitlesConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.showForcedSubtitles ?: enabled + activeProfileStore.update { it.copy(showForcedSubtitles = confirmed) } } } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt index 68f8c5476..b550919fd 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt @@ -1,10 +1,13 @@ package org.siloserver.silo.android.ui.screens.player +import kotlinx.serialization.json.Json import org.siloserver.silo.model.catalog.AudioTrack import org.siloserver.silo.playback.audioTrackFingerprint +import org.siloserver.silo.playback.resolveAudioTrackOrdinal import org.siloserver.silo.repository.port.TrackSelectionFingerprintUpdate import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull /** @@ -62,6 +65,22 @@ class MobileAudioTrackSelectionTest { ) } + @Test + fun `an original download choice restores against the online catalog`() { + val catalog = Json.decodeFromString>( + """[{"language":"eng","codec":"aac","title":"English"},{"language":"deu","codec":"aac","title":"German"}]""", + ) + val manifestTracks = catalog.mapIndexed { ordinal, track -> track.copy(index = ordinal) } + val update = assertIs(mobileAudioTrackPersistenceUpdate( + committedAudioTrackIndex = 1, + audioTracks = manifestTracks, + originalDownload = true, + )) + + assertEquals(1, resolveAudioTrackOrdinal(catalog, update.fingerprint)) + assertEquals(1, selectedServerAudioTrackIndex(1, manifestTracks)) + } + @Test fun `no committed audio preserves whatever was stored`() { assertEquals( diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracksTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracksTest.kt new file mode 100644 index 000000000..a912ed085 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracksTest.kt @@ -0,0 +1,117 @@ +package org.siloserver.silo.android.ui.screens.player + +import org.siloserver.silo.common.player.MountedSubtitleTrack +import org.siloserver.silo.common.player.resolveMountedSubtitle +import org.siloserver.silo.model.catalog.AudioTrack +import org.siloserver.silo.model.download.OfflineSubtitleFile +import org.siloserver.silo.model.playback.SubtitleIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflinePlaybackTracksTest { + + private val files = listOf( + OfflineSubtitleFile(path = "/data/app/subs/0.ass", format = "ass", language = "eng", title = "Signs & Songs"), + OfflineSubtitleFile(path = "/data/app/subs/1.sup", format = "pgs", language = "eng", forced = true), + OfflineSubtitleFile(path = "/data/app/subs/2.srt", format = "srt", language = "fre", hearingImpaired = true), + ) + + /** A prepared MP4's timed text, the two merged sidecars, and a bitmap track the menu drops. */ + private val mediaTracks = listOf( + textTrack(0, "0:3", null, "eng", "application/x-quicktime-tx3g"), + textTrack(1, "0:4", null, "spa", "application/x-quicktime-tx3g"), + textTrack(2, "1:silo-subtitle:0", "English", "eng", "text/x-ssa"), + textTrack(3, "2:silo-subtitle:1", "English", "eng", "application/pgs", forced = true), + textTrack(4, "0:5", null, "ger", "application/vobsub"), + ) + + @Test + fun sidecarRowsSkipMissingFilesAndPointAtTheLocalFile() { + val rows = offlineSidecarSubtitleRows(files) { !it.endsWith("1.sup") } + + assertEquals(listOf(0, 1), rows.map { it.index }) + assertEquals(listOf("eng", "fre"), rows.map { it.language }) + assertEquals("file:///data/app/subs/0.ass", rows[0].url) + assertEquals("Signs & Songs", rows[0].label) + assertEquals("SDH", rows[1].label) + } + + @Test + fun offlineLocalRowsNeverBecomeServerSubtitleIndexes() { + val rows = offlineSidecarSubtitleRows(files) { true } + localEmbeddedSubtitleRows(mediaTracks) + + // Cast and replans send this index to the server; a local menu position + // names no server subtitle, so the server applies preferences instead. + rows.indices.forEach { ordinal -> assertNull(selectedServerSubtitleTrackIndex(ordinal, rows)) } + assertEquals(-1, selectedServerSubtitleTrackIndex(-1, rows)) + } + + @Test + fun sidecarSelectionResolvesToItsMergedMedia3Track() { + val rows = offlineSidecarSubtitleRows(files) { true } + + val identity = assertIs(mobileSubtitleIdentity(rows[1])) + assertEquals(1, identity.serverIndex) + val match = assertNotNull(resolveMountedSubtitle(identity, mediaTracks)) + assertEquals("2:silo-subtitle:1", match.track.trackId) + } + + @Test + fun embeddedRowsListOnlyTheFilesOwnRenderableTracks() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + + assertEquals(listOf("0:3", "0:4"), rows.map { it.mediaTrackId }) + assertTrue(rows.all { it.url.isBlank() && it.source == "embedded" }) + assertTrue(rows.all { it.index >= OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE }) + val identity = assertIs(mobileSubtitleIdentity(rows[1])) + val match = assertNotNull(resolveMountedSubtitle(identity, mediaTracks)) + assertEquals("0:4", match.track.trackId) + } + + @Test + fun menuSelectionResolvesBackToTheSameRow() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + offlineSidecarSubtitleRows(files) { true } + + rows.forEachIndexed { ordinal, row -> + assertEquals(ordinal, resolveMobileSubtitleOrdinal(mobileSubtitleIdentity(row), rows)) + } + } + + @Test + fun preferencesPickASubtitleWhileTheFileDefaultFlagIsIgnored() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + offlineSidecarSubtitleRows(files) { true } + val audio = listOf( + AudioTrack(index = 0, codec = "aac", channels = 2, language = "jpn"), + AudioTrack(index = 1, codec = "aac", channels = 2, language = "eng"), + ) + + // Japanese audio with an English subtitle preference: the first + // full English text track. + assertEquals( + MobileSubtitleAutoSelection.Select(0), + resolveMobileAutoSubtitleSelection(audio, 0, rows, "eng", null, showForcedSubtitles = true), + ) + // English audio: only a forced English track may turn on. + val forced = resolveMobileAutoSubtitleSelection(audio, 1, rows, "eng", null, showForcedSubtitles = true) + val forcedRow = rows[(forced as MobileSubtitleAutoSelection.Select).ordinal] + assertEquals(true, forcedRow.forced) + // No preference: nothing turns on, whatever the file marks default. + assertEquals( + MobileSubtitleAutoSelection.NoChange, + resolveMobileAutoSubtitleSelection(audio, 0, rows, null, null, showForcedSubtitles = true), + ) + } + + private fun textTrack( + index: Int, + trackId: String, + label: String?, + language: String, + codec: String, + forced: Boolean = false, + ) = MountedSubtitleTrack(index, trackId, label, language, codec, forced, hearingImpaired = false) +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt new file mode 100644 index 000000000..7bd4ccf5d --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt @@ -0,0 +1,190 @@ +package org.siloserver.silo.android.ui.screens.settings + +import android.app.Application +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.siloserver.silo.common.player.AudiobookSettingsStore +import org.siloserver.silo.common.settings.CardPresentationUiState +import org.siloserver.silo.domain.settings.ProfileSettingsController +import org.siloserver.silo.model.profile.ActiveProfileStore +import org.siloserver.silo.model.profile.Profile +import org.siloserver.silo.model.settings.EffectiveSettingValue +import org.siloserver.silo.model.settings.EffectiveSettingValuesResponse +import org.siloserver.silo.model.settings.SeekIntervalState +import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.model.settings.SettingScopeIdentity +import org.siloserver.silo.model.settings.SettingsContractCapabilities +import org.siloserver.silo.model.settings.StoredSettingValue +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.ProfileApi +import org.siloserver.silo.network.api.SettingsApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.NotificationsV2Api +import org.siloserver.silo.network.apiv2.SettingsV2Api +import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.NotificationsRepository +import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.repository.SettingsRepository +import java.lang.reflect.Proxy +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class SettingsViewModelOfflinePreferencesTest { + @Test + fun unrelatedResolvedOverrideDoesNotDiscardAConfirmedSubtitleMode() = scenario { vm, api, profiles -> + val modeReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + val metadataReply = api.queueReply() + vm.setMetadataLanguage("fr") + runCurrent() + + // The device overrides both keys. The unrelated response replaces the + // optimistic mode before its own successful response reaches the screen. + metadataReply.complete(snapshot(mode = "off", metadataLanguage = "en")) + runCurrent() + assertEquals(SubtitleMode.OFF, vm.uiState.value.subtitleMode) + modeReply.complete(snapshot(mode = "off")) + runCurrent() + + assertEquals("off", profiles.activeProfile.value?.subtitleMode) + } + + @Test + fun anOlderResponseCannotWinWhenANewerEditReturnsToTheSameValue() = scenario { vm, api, profiles -> + val oldReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + val middleReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + val newestReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + + newestReply.complete(snapshot(mode = "off")) + runCurrent() + middleReply.complete(snapshot(mode = "always")) + runCurrent() + // This older write was narrowed by the effective settings response. + oldReply.complete(snapshot(mode = "auto")) + runCurrent() + + assertEquals(SubtitleMode.OFF, vm.uiState.value.subtitleMode) + assertEquals("off", profiles.activeProfile.value?.subtitleMode) + } + + @Test + fun aFailedNewerEditDoesNotDiscardAnEarlierConfirmedWrite() = scenario { vm, api, profiles -> + val earlierReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + api.failNextWrite = true + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + assertEquals(SubtitleMode.ALWAYS, vm.uiState.value.subtitleMode) + + earlierReply.complete(snapshot(mode = "always")) + runCurrent() + + assertEquals("always", profiles.activeProfile.value?.subtitleMode) + } + + private fun scenario( + block: suspend TestScope.(SettingsViewModel, PendingSettingsApi, ActiveProfileStore) -> Unit, + ) = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val client = HttpClient(MockEngine { respond("", HttpStatusCode.ServiceUnavailable) }) + val tokens = TokenManagerImpl() + val api = PendingSettingsApi(client, tokens) + val profiles = ActiveProfileStore(object : ProfileRepository(ProfileApi(client, ApiV2Gate.Unrestricted), tokens) { + override suspend fun getActiveProfileId() = "p1" + override suspend fun listProfiles() = ApiResult.Success(listOf(Profile(id = "p1", name = "Test", subtitleMode = "auto"))) + }) + profiles.refresh() + val vm = SettingsViewModel( + authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), + playerSettingsStore = idleStore(), + libraryPlaybackPrefsStore = idleStore(), + overlayPrefsStore = idleStore(), + activeProfileStore = profiles, + notificationsRepository = NotificationsRepository(NotificationsV2Api(client, tokens, ApiV2Gate.Unrestricted)), + profileSettings = ProfileSettingsController(SettingsRepository(api)), + cardPresentationStore = idleStore(mapOf("getState" to MutableStateFlow(CardPresentationUiState()))), + seekIntervalStore = idleStore(mapOf( + "getState" to MutableStateFlow(SeekIntervalState()), + "getLastError" to MutableStateFlow(null), + )), + audiobookSettingsStore = AudiobookSettingsStore(RuntimeEnvironment.getApplication(), { null }), + ) + try { + runCurrent() + block(vm, api, profiles) + } finally { + vm.viewModelScope.cancel() + client.close() + Dispatchers.resetMain() + } + } + + /** Unrelated settings stores do not emit or start network work in these tests. */ + private inline fun idleStore(values: Map = emptyMap()): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + values[method.name] ?: if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T + + private class PendingSettingsApi(client: HttpClient, tokens: TokenManagerImpl) : + SettingsApi(SettingsV2Api(client, tokens, ApiV2Gate.Unrestricted)) { + private val replies = ArrayDeque>>() + var failNextWrite = false + fun queueReply() = CompletableDeferred>().also(replies::addLast) + + override suspend fun getContractCapabilities() = ApiResult.Success(SettingsContractCapabilities(manifestRevision = 1)) + override suspend fun getEffectiveValues(keys: List, libraryIds: List, seriesIds: List, authority: AuthScopeSnapshot?) = + if (replies.isEmpty()) snapshot() else replies.removeFirst().await() + override suspend fun putValue(key: String, scope: SettingScopeIdentity, value: JsonElement, profileId: String?, authority: AuthScopeSnapshot?): ApiResult = + if (failNextWrite) { + failNextWrite = false + ApiResult.Error(503, "unavailable", "Unavailable") + } else { + ApiResult.Success(StoredSettingValue(key = key, scope = "profile")) + } + } + + companion object { + private fun snapshot(mode: String = "auto", metadataLanguage: String = "") = ApiResult.Success( + EffectiveSettingValuesResponse(settings = listOf( + EffectiveSettingValue(key = SettingKeys.PLAYBACK_SUBTITLE_MODE, value = JsonPrimitive(mode)), + EffectiveSettingValue(key = SettingKeys.CATALOG_METADATA_LANGUAGE, value = JsonPrimitive(metadataLanguage)), + )), + ) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt index 2bcd349d8..31681b1e2 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt @@ -68,6 +68,10 @@ data class DownloadSidecar( * used to send `If-Range` when resuming an interrupted transfer so a changed * source file restarts cleanly instead of corrupting. Null/absent = none. */ val resumeValidator: String? = null, + /** Audio tracks of the downloaded file and the subtitle sidecars saved beside + * it, captured from the offline manifest when the download completed. Null + * for downloads completed before this existed (legacy offline playback). */ + val offlineTracks: OfflineTrackInfo? = null, /** Wall-clock millis when the sidecar was last written. Diagnostic only; * helps debug stale-file scenarios via `ls -la`. */ val updatedAtMs: Long, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt new file mode 100644 index 000000000..46e9faae3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt @@ -0,0 +1,134 @@ +package org.siloserver.silo.model.download + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.siloserver.silo.model.catalog.AudioTrack + +/** + * The part of `GET /api/v2/downloads/{id}/manifest` the offline player needs: + * the audio tracks of the DELIVERED file and the subtitle sidecars to fetch. + * + * `audio_tracks[].index` is the track's position inside the downloaded file, + * not a source-file ordinal: a prepared (remux/transcode) MP4 carries every + * source audio track in source order, a legacy prepared file carries one, and + * an original download is the source file itself. + */ +@Serializable +data class OfflineManifestTracks( + @SerialName("delivery_format") val deliveryFormat: String? = null, + @SerialName("selected_audio_track_index") val selectedAudioTrackIndex: Int? = null, + @SerialName("audio_tracks") val audioTracks: List = emptyList(), + val subtitles: List = emptyList(), +) + +@Serializable +data class OfflineManifestSubtitle( + val language: String? = null, + val title: String? = null, + val format: String? = null, + val forced: Boolean = false, + @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, + @SerialName("fetch_url") val fetchUrl: String = "", +) + +/** + * Track data persisted with a [DownloadSidecar] so a download can offer its + * audio and subtitle choices with no network. Absent on downloads completed + * before this existed; those keep the legacy offline behaviour. + */ +@Serializable +data class OfflineTrackInfo( + /** + * The downloaded file's audio tracks in file order. The server numbers + * them 0..n-1 in list order, so [AudioTrack.index] and the list position + * are both the track's position among the file's Media3 audio groups. + */ + val audioTracks: List = emptyList(), + /** Position (into [audioTracks]) to select by default. */ + val selectedAudioTrackIndex: Int? = null, + val subtitles: List = emptyList(), + /** + * True for server-prepared files, whose re-encoded tracks are selected by + * position. An original download is the source file with its source + * codecs; Media3 does not report every container's audio groups in file + * order (Matroska can differ), so originals select by catalog identity. + */ + val audioByPosition: Boolean = false, +) { + /** The default audio position: the manifest's pick, else the default flag, else the first. */ + fun defaultAudioPosition(): Int { + if (audioTracks.isEmpty()) return 0 + selectedAudioTrackIndex?.takeIf { it in audioTracks.indices }?.let { return it } + return audioTracks.indexOfFirst { it.isDefault }.takeIf { it >= 0 } ?: 0 + } +} + +/** A subtitle sidecar fetched once at download time and kept beside the download. */ +@Serializable +data class OfflineSubtitleFile( + /** Absolute path of the local file. Its extension matches [format]. */ + val path: String, + /** Canonical format: `srt`, `vtt`, `ass`, `ssa`, `pgs`, or `ttml`. */ + val format: String, + val language: String? = null, + val title: String? = null, + val forced: Boolean = false, + val hearingImpaired: Boolean = false, +) + +private val offlineManifestJson = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true +} + +/** Decodes the manifest subset; null for a body that is not a manifest object. */ +fun decodeOfflineManifestTracks(body: String): OfflineManifestTracks? = + runCatching { offlineManifestJson.decodeFromString(OfflineManifestTracks.serializer(), body) }.getOrNull() + +/** Builds the persisted track data from a manifest and the sidecars saved from it. */ +fun OfflineManifestTracks.toOfflineTrackInfo(savedSubtitles: List): OfflineTrackInfo = + OfflineTrackInfo( + audioTracks = audioTracks, + selectedAudioTrackIndex = selectedAudioTrackIndex?.takeIf { it in audioTracks.indices }, + subtitles = savedSubtitles, + audioByPosition = !deliveryFormat.equals(DELIVERY_FORMAT_ORIGINAL, ignoreCase = true), + ) + +private const val DELIVERY_FORMAT_ORIGINAL = "original" + +/** + * Canonical local format for a manifest subtitle `format`, or null when the + * player cannot mount it as a sidecar (so it is not downloaded at all). + */ +fun offlineSubtitleFormat(format: String?): String? = + when (format?.trim()?.lowercase()?.removePrefix(".")) { + "srt", "subrip" -> "srt" + "vtt", "webvtt" -> "vtt" + "ass" -> "ass" + "ssa" -> "ssa" + "sup", "pgs", "hdmv_pgs_subtitle" -> "pgs" + "ttml", "dfxp" -> "ttml" + else -> null + } + +/** File extension for a canonical [offlineSubtitleFormat] value. */ +fun offlineSubtitleExtension(format: String): String = when (format) { + "pgs" -> "sup" + else -> format +} + +/** + * Only same-server managed-download subtitle proxy paths are fetched. The URL + * is authenticated with the download owner's credentials, so an absolute or + * foreign reference in a manifest must never be followed. + */ +fun isOfflineSubtitleFetchUrl(url: String): Boolean { + val trimmed = url.trim() + if (!trimmed.startsWith(OFFLINE_DOWNLOADS_PREFIX)) return false + if (trimmed.contains("://") || trimmed.contains("..") || trimmed.contains('\\')) return false + return trimmed.substring(OFFLINE_DOWNLOADS_PREFIX.length).contains("/subtitles/") +} + +private const val OFFLINE_DOWNLOADS_PREFIX = "/api/v2/downloads/" diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ActiveProfileStore.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ActiveProfileStore.kt index 4f933f9fa..1537e9be9 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ActiveProfileStore.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ActiveProfileStore.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.model.profile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.ProfileRepository @@ -71,6 +72,11 @@ class ActiveProfileStore( } } + /** Apply a local edit to the cached profile, if one is cached. */ + fun update(transform: (Profile) -> Profile) { + _activeProfile.update { it?.let(transform) } + } + /** Clear on sign-out or a server switch, so no profile leaks across accounts. */ fun reset() { loadedForProfileId = null diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt new file mode 100644 index 000000000..9d4515eb6 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt @@ -0,0 +1,146 @@ +package org.siloserver.silo.model.download + +import org.siloserver.silo.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflineTracksTest { + + /** A multi-track prepared download, shaped like the server's manifest. */ + private val preparedManifest = """ + { + "download_id": "dl_1", + "media_file_id": "4567", + "delivery_format": "transcode", + "container": "mp4", + "selected_audio_track_index": 1, + "audio_tracks": [ + {"index": 0, "language": "eng", "title": "Main", "codec": "aac", "layout": "stereo", "channels": 2, "default": true}, + {"index": 1, "language": "jpn", "codec": "aac", "channels": 2, "default": false}, + {"index": 2, "language": "eng", "title": "Commentary", "codec": "aac", "channels": 2, "default": false} + ], + "subtitles": [ + {"language": "eng", "format": "ass", "forced": false, "hearing_impaired": false, + "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:2"}, + {"language": "eng", "format": "sup", "forced": true, "hearing_impaired": false, + "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:3"} + ], + "manifest_version": 3 + } + """.trimIndent() + + @Test + fun decodesTheServerManifestIgnoringFieldsItDoesNotUse() { + val manifest = assertNotNull(decodeOfflineManifestTracks(preparedManifest)) + + assertEquals("transcode", manifest.deliveryFormat) + assertEquals(1, manifest.selectedAudioTrackIndex) + assertEquals(3, manifest.audioTracks.size) + assertEquals(listOf("ass", "sup"), manifest.subtitles.map { it.format }) + assertTrue(manifest.subtitles[1].forced) + } + + @Test + fun audioTracksKeepFilePositionsAndTheManifestSelection() { + val info = assertNotNull(decodeOfflineManifestTracks(preparedManifest)).toOfflineTrackInfo(emptyList()) + assertTrue(info.audioByPosition) + + assertEquals(listOf(0, 1, 2), info.audioTracks.map { it.index }) + assertEquals(listOf("Main", null, "Commentary"), info.audioTracks.map { it.title }) + assertEquals(1, info.defaultAudioPosition()) + assertEquals("jpn", info.audioTracks[1].language) + } + + @Test + fun originalDownloadsKeepAudioRowsWithoutPositionalSelection() { + val manifest = assertNotNull( + decodeOfflineManifestTracks(preparedManifest.replace("\"transcode\"", "\"original\"")), + ) + val sidecar = OfflineSubtitleFile(path = "subtitles/0.srt", format = "srt", language = "eng") + + val info = manifest.toOfflineTrackInfo(listOf(sidecar)) + + // Media3 may not report a source container's audio groups in file order, + // so an original file keeps its menu rows but is not selected by position. + assertFalse(info.audioByPosition) + assertEquals(3, info.audioTracks.size) + assertEquals(1, info.defaultAudioPosition()) + assertEquals(listOf(sidecar), info.subtitles) + } + + @Test + fun anOutOfRangeManifestSelectionIsDropped() { + val manifest = OfflineManifestTracks( + selectedAudioTrackIndex = 2, + audioTracks = listOf(AudioTrack(index = 0, language = "eng"), AudioTrack(index = 1, language = "fre")), + ) + + assertNull(manifest.toOfflineTrackInfo(emptyList()).selectedAudioTrackIndex) + } + + @Test + fun defaultAudioFallsBackToTheDefaultFlagThenTheFirstTrack() { + val flagged = OfflineTrackInfo( + audioTracks = listOf( + AudioTrack(index = 0, language = "eng"), + AudioTrack(index = 1, language = "fre", isDefault = true), + ), + selectedAudioTrackIndex = 7, + ) + assertEquals(1, flagged.defaultAudioPosition()) + assertEquals(0, flagged.copy(audioTracks = flagged.audioTracks.map { it.copy(isDefault = false) }).defaultAudioPosition()) + assertEquals(0, OfflineTrackInfo().defaultAudioPosition()) + } + + @Test + fun legacyPreparedManifestDescribesItsSingleTrack() { + val manifest = assertNotNull( + decodeOfflineManifestTracks( + """{"selected_audio_track_index":0,"audio_tracks":[{"index":0,"language":"en","codec":"aac","default":true}],"subtitles":[]}""", + ), + ) + val info = manifest.toOfflineTrackInfo(emptyList()) + + assertEquals(1, info.audioTracks.size) + assertEquals(0, info.defaultAudioPosition()) + assertTrue(info.subtitles.isEmpty()) + } + + @Test + fun manifestWithoutTrackFieldsDecodesEmpty() { + val manifest = assertNotNull(decodeOfflineManifestTracks("""{"download_id":"dl_1","subtitles":null}""")) + + assertTrue(manifest.audioTracks.isEmpty()) + assertTrue(manifest.subtitles.isEmpty()) + assertNull(decodeOfflineManifestTracks("not json")) + } + + @Test + fun subtitleFormatsMapToMountableLocalFormats() { + assertEquals("srt", offlineSubtitleFormat("srt")) + assertEquals("srt", offlineSubtitleFormat("SubRip")) + assertEquals("vtt", offlineSubtitleFormat("webvtt")) + assertEquals("ass", offlineSubtitleFormat("ass")) + assertEquals("ssa", offlineSubtitleFormat("ssa")) + assertEquals("pgs", offlineSubtitleFormat("sup")) + assertEquals("ttml", offlineSubtitleFormat("ttml")) + assertNull(offlineSubtitleFormat("sub")) + assertNull(offlineSubtitleFormat(null)) + assertEquals("sup", offlineSubtitleExtension("pgs")) + assertEquals("ass", offlineSubtitleExtension("ass")) + } + + @Test + fun onlyManagedDownloadSubtitleProxyPathsAreFetched() { + assertTrue(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/subtitles/embedded:2")) + assertTrue(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/subtitles/downloaded:17")) + assertFalse(isOfflineSubtitleFetchUrl("https://evil.example/api/v2/downloads/dl_1/subtitles/external:0")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/../../admin/subtitles/x")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/artwork/poster")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v1/downloads/dl_1/subtitles/external:0")) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/profile/ActiveProfileStoreTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/profile/ActiveProfileStoreTest.kt index 44d46bcd3..bda205393 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/profile/ActiveProfileStoreTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/profile/ActiveProfileStoreTest.kt @@ -68,6 +68,20 @@ class ActiveProfileStoreTest { assertEquals(2, repo.listCalls) } + @Test + fun aLocalEditUpdatesOnlyACachedProfile() = runTest { + val repo = FakeProfileRepository() + val store = ActiveProfileStore(repo) + + store.update { it.copy(subtitleLanguage = "fr") } + assertEquals(null, store.activeProfile.value) + + store.refresh() + store.update { it.copy(subtitleLanguage = "fr") } + assertEquals("fr", store.activeProfile.value?.subtitleLanguage) + assertEquals(1, repo.listCalls) + } + @Test fun aFailedRefreshKeepsTheCachedProfile() = runTest { val repo = FakeProfileRepository()