From d9904686a947e9f0fa25dd5e9d63bcac5ee73ada Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 13:58:32 +0200 Subject: [PATCH 01/12] 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. --- .../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 | 58 +- .../downloads/OfflineTrackAssetFetcher.kt | 158 +++ .../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 | 115 ++ .../SubtitleManagerTrackSelectionTest.kt | 21 + .../video/AudioReconcilePositionalTest.kt | 94 ++ .../screens/player/OfflinePlaybackTracks.kt | 93 ++ .../android/ui/screens/player/PlayerScreen.kt | 9 + .../ui/screens/player/PlayerViewModel.kt | 215 +++- .../player/OfflinePlaybackTracksTest.kt | 117 +++ .../silo/model/download/DownloadSidecar.kt | 4 + .../silo/model/download/OfflineTracks.kt | 134 +++ .../silo/model/download/OfflineTracksTest.kt | 146 +++ 22 files changed, 2298 insertions(+), 27 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 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..a57d001cc 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,24 @@ 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 + 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 +352,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 +373,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 +390,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 +469,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..ab5c330fb --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt @@ -0,0 +1,158 @@ +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 org.siloserver.silo.model.download.DownloadMediaType +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, +) { + suspend fun fetch( + downloadId: String, + serverId: String, + profileId: String, + fileId: Int, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineTrackInfo? { + val manifest = try { + val response = httpClient.get("/api/v2/downloads/${downloadId.encodeURLPathPart()}/manifest") { + configure() + } + if (response.status != HttpStatusCode.OK) { + Log.i(TAG, "manifest unavailable id=$downloadId status=${response.status.value}") + return null + } + decodeOfflineManifestTracks(response.bodyAsText()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "manifest fetch failed id=$downloadId", e) + return null + } ?: 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) + } + + 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 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..3a4305447 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt @@ -0,0 +1,115 @@ +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) = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + requested += path + when { + 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 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/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..9604dbca4 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,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.siloserver.silo.playback.orNullIfBlank import java.util.concurrent.atomic.AtomicBoolean /** @@ -157,7 +160,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( @@ -462,6 +469,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 +986,10 @@ class PlayerViewModel( upNextVideoEnded = it.upNextVideoEnded && preservesNextUp, upNextCountdownSeconds = null, stats = PlayerStatsSnapshot(), + offlineAudioByPosition = false, ) } + localSubtitleState = null } private var browseLibraryId: Int? = null @@ -1444,6 +1459,7 @@ class PlayerViewModel( subtitleRefreshNonce = 0, preferredAudioLanguage = playbackState.preferredAudioLanguage, preferredTextLanguage = playbackState.preferredTextLanguage, + offlineAudioByPosition = false, ) } @@ -3320,6 +3336,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 +3389,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 +3465,7 @@ class PlayerViewModel( selectedOrdinal = selectedOrdinal, planAudioOrdinal = state.playbackPlan?.selectedTracks?.audioIndex, requiresMountedIdentity = requiresMountedIdentity, + positionalCatalog = state.offlineAudioByPosition, ) ) { AudioReconcileAction.None -> armOriginalAudioSelectionVerification( @@ -3484,7 +3507,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 @@ -4613,6 +4638,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 +4699,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 +4731,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 +4789,116 @@ 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: $identity") + 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 active profile, then the defaults. The + * profile read is bounded so an unreachable server cannot stall playback. + */ + 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) { + try { + withTimeoutOrNull(OFFLINE_PROFILE_READ_TIMEOUT_MS) { profileRepository.getActiveProfile() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Could not read the active profile for offline subtitle preferences", e) + null + } + } 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 +4916,19 @@ 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 +/** Upper bound on the profile read that feeds offline subtitle preferences. */ +private const val OFFLINE_PROFILE_READ_TIMEOUT_MS = 2_000L + +/** + * 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/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/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/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")) + } +} From d654ba660e44710a2a3f64f780ec2cf889ba9453 Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 14:27:24 +0200 Subject: [PATCH 02/12] 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. --- .../downloads/OfflineTrackAssetFetcher.kt | 48 +++++++++++++------ .../downloads/OfflineTrackAssetFetcherTest.kt | 20 +++++++- 2 files changed, 52 insertions(+), 16 deletions(-) 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 index ab5c330fb..d88c7733f 100644 --- 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 @@ -13,7 +13,9 @@ 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 @@ -38,6 +40,7 @@ import java.io.IOException internal class OfflineTrackAssetFetcher( private val httpClient: HttpClient, private val storage: DownloadStorage, + private val manifestRetryDelayMs: Long = 2_000, ) { suspend fun fetch( downloadId: String, @@ -46,21 +49,7 @@ internal class OfflineTrackAssetFetcher( fileId: Int, configure: HttpRequestBuilder.() -> Unit, ): OfflineTrackInfo? { - val manifest = try { - val response = httpClient.get("/api/v2/downloads/${downloadId.encodeURLPathPart()}/manifest") { - configure() - } - if (response.status != HttpStatusCode.OK) { - Log.i(TAG, "manifest unavailable id=$downloadId status=${response.status.value}") - return null - } - decodeOfflineManifestTracks(response.bodyAsText()) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "manifest fetch failed id=$downloadId", e) - return null - } ?: return null + val manifest = fetchManifest(downloadId, configure) ?: return null val directory = storage.offlineSubtitleDirectory(serverId, profileId, fileId) // A replaced download (new revision) must not keep the previous @@ -76,6 +65,34 @@ internal class OfflineTrackAssetFetcher( 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, @@ -142,6 +159,7 @@ internal class OfflineTrackAssetFetcher( 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 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 index 3a4305447..a41eb6c8a 100644 --- 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 @@ -42,11 +42,16 @@ class OfflineTrackAssetFetcherTest { private val requested = mutableListOf() - private fun client(manifestStatus: HttpStatusCode = HttpStatusCode.OK) = HttpClient( + 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. @@ -105,6 +110,19 @@ class OfflineTrackAssetFetcherTest { 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")) From a2f88513cd905330a6a4adb7d6c1f7a527b051ff Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 14:41:16 +0200 Subject: [PATCH 03/12] 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. --- .../silo/android/di/AndroidModule.kt | 1 + .../ui/screens/player/PlayerViewModel.kt | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) 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/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 9604dbca4..d87ef86e6 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 @@ -291,6 +291,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. @@ -3157,7 +3160,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? = @@ -3626,7 +3632,7 @@ class PlayerViewModel( private fun persistDesiredAudio(catalogOrdinal: Int) { val state = _uiState.value val context = mobileSubtitleContext(state) - val scope = context.writeScope ?: return + val scope = finalPositionScope ?: return viewModelScope.launch { runCatching { userItemStatePort.recordTrackSelection( @@ -4872,7 +4878,8 @@ class PlayerViewModel( * 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 active profile, then the defaults. The - * profile read is bounded so an unreachable server cannot stall playback. + * cached profile is preferred; the server read is bounded so an + * unreachable server cannot stall playback. */ private suspend fun offlineSubtitlePreferences( watchDetail: org.siloserver.silo.model.catalog.WatchDetail?, @@ -4882,11 +4889,13 @@ class PlayerViewModel( val forced = watchDetail?.effectiveShowForcedSubtitles val profile = if (language == null || mode == null || forced == null) { try { - withTimeoutOrNull(OFFLINE_PROFILE_READ_TIMEOUT_MS) { profileRepository.getActiveProfile() } + val activeId = profileRepository.getActiveProfileId() + activeProfileStore?.activeProfile?.value?.takeIf { it.id == activeId } + ?: withTimeoutOrNull(OFFLINE_PROFILE_READ_TIMEOUT_MS) { profileRepository.getActiveProfile() } } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.w(TAG, "Could not read the active profile for offline subtitle preferences", e) + Log.w(TAG, "Could not read the active profile for offline subtitle preferences: ${e.javaClass.simpleName}") null } } else { From 531d180c1ffbdc7f04429384241e259bee130d47 Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 14:43:48 +0200 Subject: [PATCH 04/12] 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. --- .../ui/screens/player/PlayerViewModel.kt | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) 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 d87ef86e6..d5ecf6ba3 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 @@ -136,7 +136,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import org.siloserver.silo.playback.orNullIfBlank import java.util.concurrent.atomic.AtomicBoolean @@ -4877,9 +4876,9 @@ class PlayerViewModel( /** * 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 active profile, then the defaults. The - * cached profile is preferred; the server read is bounded so an - * unreachable server cannot stall playback. + * 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?, @@ -4888,16 +4887,8 @@ class PlayerViewModel( val mode = watchDetail?.effectiveSubtitleMode.orNullIfBlank() val forced = watchDetail?.effectiveShowForcedSubtitles val profile = if (language == null || mode == null || forced == null) { - try { - val activeId = profileRepository.getActiveProfileId() - activeProfileStore?.activeProfile?.value?.takeIf { it.id == activeId } - ?: withTimeoutOrNull(OFFLINE_PROFILE_READ_TIMEOUT_MS) { profileRepository.getActiveProfile() } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Could not read the active profile for offline subtitle preferences: ${e.javaClass.simpleName}") - null - } + val activeId = profileRepository.getActiveProfileId() + activeProfileStore?.activeProfile?.value?.takeIf { it.id == activeId } } else { null } @@ -4925,9 +4916,6 @@ 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 -/** Upper bound on the profile read that feeds offline subtitle preferences. */ -private const val OFFLINE_PROFILE_READ_TIMEOUT_MS = 2_000L - /** * A downloaded file mounted by the offline-first path: a local URI with no * server session or playback plan behind it. From 0b03d522a4898d4018c97a724db77aaac6f3570a Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 14:50:12 +0200 Subject: [PATCH 05/12] 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. --- .../silo/android/ui/screens/player/PlayerViewModel.kt | 3 +++ .../silo/android/ui/screens/settings/SettingsViewModel.kt | 3 +++ 2 files changed, 6 insertions(+) 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 d5ecf6ba3..849ef9231 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 @@ -3630,6 +3630,9 @@ 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 = finalPositionScope ?: return viewModelScope.launch { 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..1ff6cf2e8 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 @@ -621,6 +621,9 @@ class SettingsViewModel( edited: String, fieldOf: (ProfileSettingsController.Snapshot) -> String, ) { + // The cached profile feeds offline subtitle preferences; keep it on + // the row just written. + viewModelScope.launch { activeProfileStore.refresh(force = true) } if (snapshot == null) return if (fieldOf(snapshot) == edited) { _uiState.update { From 5792664ba2e7eb6a2012fe0385e5f3442f205672 Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 15:01:14 +0200 Subject: [PATCH 06/12] 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. --- .../ui/screens/settings/SettingsViewModel.kt | 12 +++++++++--- .../silo/model/profile/ActiveProfileStore.kt | 6 ++++++ .../silo/model/profile/ActiveProfileStoreTest.kt | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) 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 1ff6cf2e8..6f69f3661 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 @@ -621,10 +621,16 @@ class SettingsViewModel( edited: String, fieldOf: (ProfileSettingsController.Snapshot) -> String, ) { - // The cached profile feeds offline subtitle preferences; keep it on - // the row just written. - viewModelScope.launch { activeProfileStore.refresh(force = true) } if (snapshot == null) return + // Offline subtitle preferences read the cached profile; keep it on + // what the server now resolves. + activeProfileStore.update { + it.copy( + subtitleLanguage = snapshot.subtitleLanguage, + subtitleMode = snapshot.subtitleMode, + showForcedSubtitles = snapshot.showForcedSubtitles, + ) + } if (fieldOf(snapshot) == edited) { _uiState.update { it.copy( 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/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() From 0764c2dc332c8139e703c0d8f67ed49ca1eeeea1 Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 15:12:57 +0200 Subject: [PATCH 07/12] 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. --- .../ui/screens/settings/SettingsViewModel.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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 6f69f3661..5fbb3bc26 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 @@ -621,16 +621,25 @@ class SettingsViewModel( edited: String, fieldOf: (ProfileSettingsController.Snapshot) -> String, ) { - if (snapshot == null) return - // Offline subtitle preferences read the cached profile; keep it on - // what the server now resolves. + if (snapshot != null) applySnapshot(snapshot, edited, fieldOf) + // Offline subtitle preferences read the cached profile. Mirror what + // this screen now shows: the guards above already settled which + // response wins, and a write with no re-read keeps its own value. + val shown = _uiState.value activeProfileStore.update { it.copy( - subtitleLanguage = snapshot.subtitleLanguage, - subtitleMode = snapshot.subtitleMode, - showForcedSubtitles = snapshot.showForcedSubtitles, + subtitleLanguage = shown.subtitleLanguage, + subtitleMode = shown.subtitleMode.wire, + showForcedSubtitles = shown.showForcedSubtitles, ) } + } + + private fun applySnapshot( + snapshot: ProfileSettingsController.Snapshot, + edited: String, + fieldOf: (ProfileSettingsController.Snapshot) -> String, + ) { if (fieldOf(snapshot) == edited) { _uiState.update { it.copy( From 59c0eb395046191b48b3ca6f72c7a96160c53592 Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 15:23:01 +0200 Subject: [PATCH 08/12] 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. --- .../ui/screens/settings/SettingsViewModel.kt | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) 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 5fbb3bc26..3a2595b98 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 @@ -557,6 +557,11 @@ 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. A write that a newer edit of the same field superseded + // leaves the cache to that newer write. + /** [language] is a BCP 47 tag, or "" for off. */ fun setSubtitleLanguage(language: String) { val previous = _uiState.value.subtitleLanguage @@ -568,7 +573,12 @@ class SettingsViewModel( if (it.subtitleLanguage == language) it.copy(subtitleLanguage = previous) else it } } else { + val current = _uiState.value.subtitleLanguage == language applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + if (current) { + val confirmed = result.snapshot?.subtitleLanguage ?: language + activeProfileStore.update { it.copy(subtitleLanguage = confirmed) } + } } } } @@ -583,7 +593,12 @@ class SettingsViewModel( if (it.subtitleMode == mode) it.copy(subtitleMode = previous) else it } } else { + val current = _uiState.value.subtitleMode == mode applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + if (current) { + val confirmed = result.snapshot?.subtitleMode ?: mode.wire + activeProfileStore.update { it.copy(subtitleMode = confirmed) } + } } } } @@ -598,9 +613,14 @@ class SettingsViewModel( if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it } } else { + val current = _uiState.value.showForcedSubtitles == enabled applyResolved(result.snapshot, edited = enabled.toString()) { it.showForcedSubtitles.toString() } + if (current) { + val confirmed = result.snapshot?.showForcedSubtitles ?: enabled + activeProfileStore.update { it.copy(showForcedSubtitles = confirmed) } + } } } } @@ -621,25 +641,7 @@ class SettingsViewModel( edited: String, fieldOf: (ProfileSettingsController.Snapshot) -> String, ) { - if (snapshot != null) applySnapshot(snapshot, edited, fieldOf) - // Offline subtitle preferences read the cached profile. Mirror what - // this screen now shows: the guards above already settled which - // response wins, and a write with no re-read keeps its own value. - val shown = _uiState.value - activeProfileStore.update { - it.copy( - subtitleLanguage = shown.subtitleLanguage, - subtitleMode = shown.subtitleMode.wire, - showForcedSubtitles = shown.showForcedSubtitles, - ) - } - } - - private fun applySnapshot( - snapshot: ProfileSettingsController.Snapshot, - edited: String, - fieldOf: (ProfileSettingsController.Snapshot) -> String, - ) { + if (snapshot == null) return if (fieldOf(snapshot) == edited) { _uiState.update { it.copy( From 96256391da5d246f9bb0212dcd65619e2eb7075a Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:13:42 -0400 Subject: [PATCH 09/12] 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 --- .../screens/downloads/DownloadsViewModel.kt | 7 +- .../ui/screens/player/PlayerViewModel.kt | 5 + .../ui/screens/settings/SettingsViewModel.kt | 23 ++- .../player/MobileAudioTrackSelectionTest.kt | 19 ++ ...SettingsViewModelOfflinePreferencesTest.kt | 189 ++++++++++++++++++ 5 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt 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/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 849ef9231..82619d34e 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 @@ -232,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 @@ -3644,6 +3648,7 @@ class PlayerViewModel( audioUpdate = mobileAudioTrackPersistenceUpdate( committedAudioTrackIndex = catalogOrdinal, audioTracks = context.audioTracks, + originalDownload = state.isLocalFilePlayback(), ), // Untouched: this path changed audio only. subtitleUpdate = TrackSelectionFingerprintUpdate.Preserve, 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 3a2595b98..e1998c505 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) @@ -559,11 +566,12 @@ 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. A write that a newer edit of the same field superseded - // leaves the cache to that newer write. + // 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 { @@ -573,9 +581,10 @@ class SettingsViewModel( if (it.subtitleLanguage == language) it.copy(subtitleLanguage = previous) else it } } else { - val current = _uiState.value.subtitleLanguage == language + val current = editGeneration > subtitleLanguageConfirmedGeneration applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } if (current) { + subtitleLanguageConfirmedGeneration = editGeneration val confirmed = result.snapshot?.subtitleLanguage ?: language activeProfileStore.update { it.copy(subtitleLanguage = confirmed) } } @@ -584,6 +593,7 @@ class SettingsViewModel( } fun setSubtitleMode(mode: SubtitleMode) { + val editGeneration = ++subtitleModeEditGeneration val previous = _uiState.value.subtitleMode _uiState.update { it.copy(subtitleMode = mode) } viewModelScope.launch { @@ -593,9 +603,10 @@ class SettingsViewModel( if (it.subtitleMode == mode) it.copy(subtitleMode = previous) else it } } else { - val current = _uiState.value.subtitleMode == mode + val current = editGeneration > subtitleModeConfirmedGeneration applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } if (current) { + subtitleModeConfirmedGeneration = editGeneration val confirmed = result.snapshot?.subtitleMode ?: mode.wire activeProfileStore.update { it.copy(subtitleMode = confirmed) } } @@ -604,6 +615,7 @@ class SettingsViewModel( } fun setShowForcedSubtitles(enabled: Boolean) { + val editGeneration = ++forcedSubtitlesEditGeneration val previous = _uiState.value.showForcedSubtitles _uiState.update { it.copy(showForcedSubtitles = enabled) } viewModelScope.launch { @@ -613,11 +625,12 @@ class SettingsViewModel( if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it } } else { - val current = _uiState.value.showForcedSubtitles == enabled + val current = editGeneration > forcedSubtitlesConfirmedGeneration applyResolved(result.snapshot, edited = enabled.toString()) { it.showForcedSubtitles.toString() } if (current) { + 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/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt new file mode 100644 index 000000000..fd681e523 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt @@ -0,0 +1,189 @@ +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("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)), + )), + ) + } +} From b218ad160e324a591486f2bd41cc1f80bdac11f5 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:28:09 -0400 Subject: [PATCH 10/12] 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) --- .../ui/screens/settings/SettingsViewModel.kt | 19 ++++++++----------- ...SettingsViewModelOfflinePreferencesTest.kt | 1 + 2 files changed, 9 insertions(+), 11 deletions(-) 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 e1998c505..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 @@ -581,9 +581,8 @@ class SettingsViewModel( if (it.subtitleLanguage == language) it.copy(subtitleLanguage = previous) else it } } else { - val current = editGeneration > subtitleLanguageConfirmedGeneration - applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } - if (current) { + if (editGeneration > subtitleLanguageConfirmedGeneration) { + applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } subtitleLanguageConfirmedGeneration = editGeneration val confirmed = result.snapshot?.subtitleLanguage ?: language activeProfileStore.update { it.copy(subtitleLanguage = confirmed) } @@ -603,9 +602,8 @@ class SettingsViewModel( if (it.subtitleMode == mode) it.copy(subtitleMode = previous) else it } } else { - val current = editGeneration > subtitleModeConfirmedGeneration - applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } - if (current) { + 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) } @@ -625,11 +623,10 @@ class SettingsViewModel( if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it } } else { - val current = editGeneration > forcedSubtitlesConfirmedGeneration - applyResolved(result.snapshot, edited = enabled.toString()) { - it.showForcedSubtitles.toString() - } - if (current) { + 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/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt index fd681e523..7bd4ccf5d 100644 --- 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 @@ -98,6 +98,7 @@ class SettingsViewModelOfflinePreferencesTest { oldReply.complete(snapshot(mode = "auto")) runCurrent() + assertEquals(SubtitleMode.OFF, vm.uiState.value.subtitleMode) assertEquals("off", profiles.activeProfile.value?.subtitleMode) } From b7947199188fd073f23aa2296ccf98ff21f50daa Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:06:27 -0400 Subject: [PATCH 11/12] 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 --- .../siloserver/silo/common/downloads/DownloadWorker.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 a57d001cc..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 @@ -158,6 +158,14 @@ class DownloadWorker( 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) } From c694c304e1f60f19f4393b1e00bc1ae221c1028f Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:21:19 -0400 Subject: [PATCH 12/12] 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 --- .../silo/android/ui/screens/player/PlayerViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 82619d34e..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 @@ -4876,7 +4876,7 @@ class PlayerViewModel( -> null } ?: return if (identity == published.committedSubtitleIdentity) return - Log.i(TAG, "local subtitle auto-selection: $identity") + Log.i(TAG, "local subtitle auto-selection applied") mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(published)) mobileSubtitleTransactions.select(identity) }