diff --git a/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/13.json b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/13.json new file mode 100644 index 000000000..5d3dc4bad --- /dev/null +++ b/android-shared/schemas/org.siloserver.silo.common.data.db.SiloDatabase/13.json @@ -0,0 +1,989 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "86a5233fe50f7b265ded23af8525415e", + "entities": [ + { + "tableName": "user_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `fileId` INTEGER NOT NULL, `positionSeconds` REAL NOT NULL, `durationSeconds` REAL, `audioFingerprint` TEXT, `subtitleFingerprint` TEXT, `cfi` TEXT, `readProgress` REAL, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`, `fileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionSeconds", + "columnName": "positionSeconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "audioFingerprint", + "columnName": "audioFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleFingerprint", + "columnName": "subtitleFingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "cfi", + "columnName": "cfi", + "affinity": "TEXT" + }, + { + "fieldPath": "readProgress", + "columnName": "readProgress", + "affinity": "REAL" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId", + "fileId" + ] + }, + "indices": [ + { + "name": "index_user_item_state_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_user_item_state_serverId_profileId_clientUpdatedAtMs", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "clientUpdatedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_item_state_serverId_profileId_clientUpdatedAtMs` ON `${TABLE_NAME}` (`serverId`, `profileId`, `clientUpdatedAtMs`)" + } + ] + }, + { + "tableName": "content_item_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `watched` INTEGER, `ratingValue` INTEGER, `favorite` INTEGER, `clientUpdatedAtMs` INTEGER NOT NULL, `serverUpdatedAtMs` INTEGER, PRIMARY KEY(`serverId`, `profileId`, `contentId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER" + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientUpdatedAtMs", + "columnName": "clientUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverUpdatedAtMs", + "columnName": "serverUpdatedAtMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ] + } + }, + { + "tableName": "dirty_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opKind` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetContentId` TEXT NOT NULL, `targetFileId` INTEGER, `coalesceKey` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `opVersion` INTEGER NOT NULL, `payloadJson` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAtMs` INTEGER, `nextAttemptAtMs` INTEGER NOT NULL, `lastError` TEXT, `membershipAuthority` TEXT, `membershipClaim` TEXT, `membershipOwner` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opKind", + "columnName": "opKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetContentId", + "columnName": "targetContentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetFileId", + "columnName": "targetFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coalesceKey", + "columnName": "coalesceKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opVersion", + "columnName": "opVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAtMs", + "columnName": "lastAttemptAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextAttemptAtMs", + "columnName": "nextAttemptAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipAuthority", + "columnName": "membershipAuthority", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipClaim", + "columnName": "membershipClaim", + "affinity": "TEXT" + }, + { + "fieldPath": "membershipOwner", + "columnName": "membershipOwner", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_dirty_operations_idempotencyKey", + "unique": true, + "columnNames": [ + "idempotencyKey" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dirty_operations_idempotencyKey` ON `${TABLE_NAME}` (`idempotencyKey`)" + }, + { + "name": "index_dirty_operations_coalesceKey", + "unique": false, + "columnNames": [ + "coalesceKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_coalesceKey` ON `${TABLE_NAME}` (`coalesceKey`)" + }, + { + "name": "index_dirty_operations_nextAttemptAtMs_id", + "unique": false, + "columnNames": [ + "nextAttemptAtMs", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dirty_operations_nextAttemptAtMs_id` ON `${TABLE_NAME}` (`nextAttemptAtMs`, `id`)" + } + ] + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `mediaFileId` INTEGER NOT NULL, `recordId` TEXT NOT NULL, `contentId` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `posterUrl` TEXT, `posterThumbhash` TEXT, `year` INTEGER, `seriesTitle` TEXT, `seriesContentId` TEXT, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `fileName` TEXT, `container` TEXT, `localUri` TEXT, `mediaType` TEXT NOT NULL, `overview` TEXT, `author` TEXT, `narrator` TEXT, `durationSeconds` REAL, `chaptersJson` TEXT, `status` TEXT NOT NULL, `kind` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `bytesSent` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `completedAt` TEXT, `updatedAtMs` INTEGER NOT NULL, `resumeValidator` TEXT, `quality` TEXT, `effectiveQuality` TEXT, `revision` INTEGER, `offlineTracksJson` TEXT, PRIMARY KEY(`serverId`, `profileId`, `mediaFileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "posterUrl", + "columnName": "posterUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "posterThumbhash", + "columnName": "posterThumbhash", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "seriesTitle", + "columnName": "seriesTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "seriesContentId", + "columnName": "seriesContentId", + "affinity": "TEXT" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "container", + "columnName": "container", + "affinity": "TEXT" + }, + { + "fieldPath": "localUri", + "columnName": "localUri", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "narrator", + "columnName": "narrator", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "durationSeconds", + "affinity": "REAL" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesSent", + "columnName": "bytesSent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeValidator", + "columnName": "resumeValidator", + "affinity": "TEXT" + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT" + }, + { + "fieldPath": "effectiveQuality", + "columnName": "effectiveQuality", + "affinity": "TEXT" + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER" + }, + { + "fieldPath": "offlineTracksJson", + "columnName": "offlineTracksJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "mediaFileId" + ] + }, + "indices": [ + { + "name": "index_downloads_serverId_profileId_status", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_status` ON `${TABLE_NAME}` (`serverId`, `profileId`, `status`)" + }, + { + "name": "index_downloads_serverId_profileId_contentId", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "contentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_downloads_serverId_profileId_contentId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `contentId`)" + }, + { + "name": "index_downloads_recordId", + "unique": true, + "columnNames": [ + "recordId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloads_recordId` ON `${TABLE_NAME}` (`recordId`)" + } + ] + }, + { + "tableName": "legacy_imports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKind` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `sourceHash` TEXT NOT NULL, `sourceMtimeMs` INTEGER NOT NULL, `importedAtMs` INTEGER NOT NULL, `importVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceHash", + "columnName": "sourceHash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceMtimeMs", + "columnName": "sourceMtimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "importVersion", + "columnName": "importVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_legacy_imports_sourceKind_sourcePath", + "unique": true, + "columnNames": [ + "sourceKind", + "sourcePath" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_legacy_imports_sourceKind_sourcePath` ON `${TABLE_NAME}` (`sourceKind`, `sourcePath`)" + } + ] + }, + { + "tableName": "home_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `sectionsJson` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionsJson", + "columnName": "sectionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId" + ] + } + }, + { + "tableName": "catalog_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `cacheKey` TEXT NOT NULL, `json` TEXT NOT NULL, `cachedAtMs` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `profileId`, `cacheKey`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "cacheKey" + ] + } + }, + { + "tableName": "download_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `recordId` TEXT NOT NULL, `mediaFileId` INTEGER, `enqueuedAtMs` INTEGER NOT NULL, `loginId` TEXT, `origin` TEXT, `deviceId` TEXT, PRIMARY KEY(`serverId`, `profileId`, `recordId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "enqueuedAtMs", + "columnName": "enqueuedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loginId", + "columnName": "loginId", + "affinity": "TEXT" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "recordId" + ] + } + }, + { + "tableName": "download_subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` TEXT NOT NULL, `profileId` TEXT NOT NULL, `targetType` TEXT NOT NULL, `targetId` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `mediaKind` TEXT NOT NULL, `quality` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `includeExisting` INTEGER NOT NULL, `keepUnwatchedLimit` INTEGER NOT NULL, `deleteWatchedAfterDays` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastEvaluatedAt` INTEGER, `lastError` TEXT, PRIMARY KEY(`serverId`, `profileId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeExisting", + "columnName": "includeExisting", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepUnwatchedLimit", + "columnName": "keepUnwatchedLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteWatchedAfterDays", + "columnName": "deleteWatchedAfterDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEvaluatedAt", + "columnName": "lastEvaluatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "profileId", + "id" + ] + }, + "indices": [ + { + "name": "index_download_subscriptions_serverId_profileId_enabled", + "unique": false, + "columnNames": [ + "serverId", + "profileId", + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_enabled` ON `${TABLE_NAME}` (`serverId`, `profileId`, `enabled`)" + }, + { + "name": "index_download_subscriptions_serverId_profileId_targetType_targetId", + "unique": true, + "columnNames": [ + "serverId", + "profileId", + "targetType", + "targetId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_subscriptions_serverId_profileId_targetType_targetId` ON `${TABLE_NAME}` (`serverId`, `profileId`, `targetType`, `targetId`)" + } + ] + }, + { + "tableName": "membership_projection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `itemId` TEXT NOT NULL, `kind` TEXT NOT NULL, `commandId` INTEGER NOT NULL, `present` INTEGER NOT NULL, `disposition` TEXT, PRIMARY KEY(`authority`, `itemId`, `kind`))", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "present", + "columnName": "present", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "authority", + "itemId", + "kind" + ] + } + }, + { + "tableName": "legacy_membership_quarantine", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`commandId` INTEGER NOT NULL, `originalState` TEXT NOT NULL, PRIMARY KEY(`commandId`))", + "fields": [ + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalState", + "columnName": "originalState", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "commandId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '86a5233fe50f7b265ded23af8525415e')" + ] + } +} \ No newline at end of file diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt index f690dc1d8..bff238b76 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/SiloDatabase.kt @@ -56,7 +56,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity MembershipProjectionEntity::class, LegacyMembershipQuarantineEntity::class, ], - version = 12, + version = 13, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -69,6 +69,7 @@ import org.siloserver.silo.common.data.db.entity.UserItemStateEntity AutoMigration(from = 8, to = 9), AutoMigration(from = 10, to = 11), AutoMigration(from = 11, to = 12), + AutoMigration(from = 12, to = 13), ], ) abstract class SiloDatabase : RoomDatabase() { diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt index 4ce7c95f4..ea39b119e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/data/db/entity/DownloadEntity.kt @@ -82,4 +82,8 @@ data class DownloadEntity( /** Registry revision whose bytes this row describes. A local completion * only speaks for this revision; null on rows written before v12. */ val revision: Int? = null, + /** Serialized [org.siloserver.silo.model.download.OfflineTrackInfo]: the + * downloaded file's audio tracks and the local subtitle sidecars. Null for + * downloads completed before offline track data was captured. */ + val offlineTracksJson: String? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt index 77478cbf6..dd859337d 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarMapping.kt @@ -4,6 +4,7 @@ import org.siloserver.silo.common.data.db.entity.DownloadEntity import org.siloserver.silo.model.catalog.VersionChapter import org.siloserver.silo.model.download.DownloadRecord import org.siloserver.silo.model.download.DownloadSidecar +import org.siloserver.silo.model.download.OfflineTrackInfo import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -58,6 +59,7 @@ fun DownloadSidecar.toEntity(serverId: String, profileId: String): DownloadEntit quality = record.quality, effectiveQuality = record.effectiveQuality, revision = record.revision, + offlineTracksJson = offlineTracks?.let { mappingJson.encodeToString(it) }, ) fun DownloadEntity.toSidecar(): DownloadSidecar = @@ -97,5 +99,8 @@ fun DownloadEntity.toSidecar(): DownloadSidecar = durationSeconds = durationSeconds, chapters = chaptersJson?.let { runCatching { mappingJson.decodeFromString>(it) }.getOrNull() }, resumeValidator = resumeValidator, + offlineTracks = offlineTracksJson?.let { + runCatching { mappingJson.decodeFromString(it) }.getOrNull() + }, updatedAtMs = updatedAtMs, ) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt index acf317f73..33321250e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadStorage.kt @@ -106,8 +106,10 @@ class DownloadStorage( * directories are left in place; cleanup at higher granularity goes * through [deleteAllForProfile] or [deleteAllForServer]. */ - fun delete(serverId: String, profileId: String, fileId: Int): Boolean = - publicStore.delete(serverId, profileId, fileId, uriString = null) + fun delete(serverId: String, profileId: String, fileId: Int): Boolean { + containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString())?.deleteRecursively() + return publicStore.delete(serverId, profileId, fileId, uriString = null) + } fun completeWrite(uriString: String): String = publicStore.complete(uriString) @@ -133,12 +135,27 @@ class DownloadStorage( /** Wipes every downloaded byte under (serverId, profileId). Used on sign-out * and profile switch. Metadata rows are cleared via [DownloadMetadataStore]. */ - fun deleteAllForProfile(serverId: String, profileId: String): Boolean = - publicStore.deleteAllForProfile(serverId, profileId) + fun deleteAllForProfile(serverId: String, profileId: String): Boolean { + containedSafeChild(offlineAssetsRoot, serverId, profileId)?.deleteRecursively() + return publicStore.deleteAllForProfile(serverId, profileId) + } /** Wipes every downloaded byte under (serverId). Used on server delete / re-bind. */ - fun deleteAllForServer(serverId: String): Boolean = - publicStore.deleteAllForServer(serverId) + fun deleteAllForServer(serverId: String): Boolean { + containedSafeChild(offlineAssetsRoot, serverId)?.deleteRecursively() + return publicStore.deleteAllForServer(serverId) + } + + /** + * Private directory for the offline subtitle sidecars of one download. + * Kept in app-internal storage rather than beside the (public, possibly + * MediaStore-owned) media file: they are only meaningful to this app, and + * a scoped directory is removed with the download by [delete]. + */ + fun offlineSubtitleDirectory(serverId: String, profileId: String, fileId: Int): File? = + containedSafeChild(offlineAssetsRoot, serverId, profileId, fileId.toString(), SUBTITLES_DIR) + + private val offlineAssetsRoot: File get() = File(baseDir, OFFLINE_ASSETS_DIR) /** Sum of bytes across every downloaded file under this storage. */ fun totalBytesUsed(): Long = publicStore.totalBytesUsed() @@ -170,6 +187,10 @@ class DownloadStorage( private fun sanitizeBasename(value: String): String = value.replace(Regex("[\\\\/:*?\"<>|]"), "_") + private companion object { + const val OFFLINE_ASSETS_DIR = "download-assets" + const val SUBTITLES_DIR = "subtitles" + } } data class DownloadTarget( diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt index c909952cd..8c668ecab 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/DownloadWorker.kt @@ -92,6 +92,7 @@ class DownloadWorker( ) : CoroutineWorker(appContext, params) { private var transferAuthority: DurableLoginAuthority? = null + private val offlineTrackFetcher = OfflineTrackAssetFetcher(httpClient, storage) private suspend fun requireOwner() { if (authorities != null && (transferAuthority == null || transferAuthority != authorities.snapshotDurableLoginAuthority() || inputData.getString(KEY_DEVICE_ID) != devices?.current()?.id)) throw DownloadOwnerChanged() } @@ -144,8 +145,32 @@ class DownloadWorker( }.onFailure { Log.w(TAG, "setForeground initial failed", it) } var activeUri: String? = null + // Capture can outlive media publication. A stop then preserves the + // completed file and its queued completion report. + var mediaPublished = false + try { var existing = runCatching { metadataStore.readSidecar(serverId, profileId, fileId) }.getOrNull() + // A stopped capture resumes from the completed local file, including + // legacy rows whose original transfer revision is unknown. + if (existing != null && existing.record.id == downloadId && + existing.record.statusEnum() == DownloadStatus.Completed && + storage.locateLocalMedia(serverId, profileId, fileId) != null + ) { + mediaPublished = true + requireOwner() + // Publication and status enqueue are separate durable writes. + // Recover a stop between them using the revision of these bytes. + reportStatus( + downloadId, DownloadStatus.Completed, + maxOf(System.currentTimeMillis(), existing.updatedAtMs + 1), + existing.record.revision?.takeIf { it > 0 }, serverId, profileId, + ) + if (existing.offlineTracks == null) { + captureOfflineTracks(downloadId, serverId, profileId, fileId, mediaType) + } + return@withContext Result.success() + } // Old WorkManager jobs have no revision input. Resolve it as their owner // and save it in Room so another attempt does not depend on a UI cache. val revision = inputData.getInt(KEY_REVISION, 0).takeIf { it > 0 } @@ -335,6 +360,7 @@ class DownloadWorker( ) true } + mediaPublished = true Log.i(TAG, "doWork success id=$downloadId bytes=$finalBytes") DiagnosticsDownloadLogger.event("download completed") // Strictly after the `downloading` event: the server ignores an event @@ -355,6 +381,7 @@ class DownloadWorker( } // The v2 file route never marks the entry completed; this report does. reportStatus(downloadId, DownloadStatus.Completed, completedAtMs, revision, serverId, profileId) + captureOfflineTracks(downloadId, serverId, profileId, fileId, mediaType) Result.success(workDataOf(KEY_BYTES_WRITTEN to finalBytes, KEY_TOTAL_BYTES to finalBytes)) } catch (e: DownloadOwnerChanged) { // Preserve the original owner's partial; a new login cannot resume it. @@ -371,7 +398,7 @@ class DownloadWorker( // downloads with a red badge and delete-then-fail them. Log.i(TAG, "doWork cancelled id=$downloadId") DiagnosticsDownloadLogger.event("download cancelled") - withContext(NonCancellable) { + if (!mediaPublished) withContext(NonCancellable) { // WorkManager's notification action cancels this transfer directly. // Its separate status job must stop before we remove the bytes. runCatching { DownloadStatusWorker.cancel(appContext, downloadId) } @@ -450,6 +477,43 @@ class DownloadWorker( }.onFailure { Log.w(TAG, "status report enqueue failed id=$downloadId status=${status.wire}", it) } } + /** + * Fetches the offline manifest's audio tracks and subtitle sidecars for a + * published video download and stores them on its metadata row. Best + * effort: every failure leaves the download playable the legacy way. + */ + private suspend fun captureOfflineTracks( + downloadId: String, + serverId: String, + profileId: String, + fileId: Int, + mediaType: String?, + ) { + if (!OfflineTrackAssetFetcher.appliesTo(mediaType)) return + try { + requireOwner() + val tracks = offlineTrackFetcher.fetch(downloadId, serverId, profileId, fileId) { + transferAuthority?.let { managedDownloadAuth(it.scope) } + } ?: return + ownedWrite { + val existing = metadataStore.readSidecar(serverId, profileId, fileId) + ?.takeIf { it.record.id == downloadId } + if (existing != null) { + metadataStore.writeSidecar( + serverId, profileId, + existing.copy(offlineTracks = tracks, updatedAtMs = System.currentTimeMillis()), + ) + } + true + } + Log.i(TAG, "offline tracks captured id=$downloadId audio=${tracks.audioTracks.size} subtitles=${tracks.subtitles.size}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "offline track capture skipped id=$downloadId", e) + } + } + /** Permanent failure — clean up local file and let the user retry manually. */ private suspend fun failPermanently( e: Throwable, diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt new file mode 100644 index 000000000..d88c7733f --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcher.kt @@ -0,0 +1,176 @@ +package org.siloserver.silo.common.downloads + +import android.util.Log +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeoutConfig +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.client.request.prepareGet +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.encodeURLPathPart +import io.ktor.utils.io.jvm.javaio.toInputStream +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import org.siloserver.silo.model.download.DownloadMediaType +import org.siloserver.silo.model.download.OfflineManifestTracks +import org.siloserver.silo.model.download.OfflineManifestSubtitle +import org.siloserver.silo.model.download.OfflineSubtitleFile +import org.siloserver.silo.model.download.OfflineTrackInfo +import org.siloserver.silo.model.download.decodeOfflineManifestTracks +import org.siloserver.silo.model.download.isOfflineSubtitleFetchUrl +import org.siloserver.silo.model.download.offlineSubtitleExtension +import org.siloserver.silo.model.download.offlineSubtitleFormat +import org.siloserver.silo.model.download.toOfflineTrackInfo +import org.siloserver.silo.playback.orNullIfBlank +import java.io.File +import java.io.IOException + +/** + * Captures what offline video playback needs once the media bytes are down: + * the offline manifest's audio tracks (positions inside the delivered file) and + * the subtitle sidecars it lists, each fetched once into private storage. + * + * Everything here is best effort. A missing manifest, an older server, or a + * sidecar that fails to fetch never fails the video download; the download + * then plays with whatever was captured (or the legacy offline behaviour). + */ +internal class OfflineTrackAssetFetcher( + private val httpClient: HttpClient, + private val storage: DownloadStorage, + private val manifestRetryDelayMs: Long = 2_000, +) { + suspend fun fetch( + downloadId: String, + serverId: String, + profileId: String, + fileId: Int, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineTrackInfo? { + val manifest = fetchManifest(downloadId, configure) ?: return null + + val directory = storage.offlineSubtitleDirectory(serverId, profileId, fileId) + // A replaced download (new revision) must not keep the previous + // revision's sidecars around under the same file slot. + directory?.deleteRecursively() + val saved = if (directory == null || manifest.subtitles.isEmpty()) { + emptyList() + } else { + manifest.subtitles.mapIndexedNotNull { ordinal, subtitle -> + fetchSubtitle(downloadId, ordinal, subtitle, directory, configure) + } + } + return manifest.toOfflineTrackInfo(saved) + } + + /** + * The capture runs once, right after the download completes, so a network + * blip or a transient server error is retried briefly here; a missing + * manifest (older server) is not. + */ + private suspend fun fetchManifest( + downloadId: String, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineManifestTracks? { + repeat(MANIFEST_ATTEMPTS) { attempt -> + if (attempt > 0) delay(manifestRetryDelayMs * attempt) + try { + val response = httpClient.get("/api/v2/downloads/${downloadId.encodeURLPathPart()}/manifest") { + configure() + } + val status = response.status + if (status == HttpStatusCode.OK) return decodeOfflineManifestTracks(response.bodyAsText()) + Log.i(TAG, "manifest unavailable id=$downloadId status=${status.value}") + if (status.value < 500 && status != HttpStatusCode.TooManyRequests) return null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "manifest fetch failed id=$downloadId attempt=${attempt + 1}", e) + } + } + return null + } + + private suspend fun fetchSubtitle( + downloadId: String, + ordinal: Int, + subtitle: OfflineManifestSubtitle, + directory: File, + configure: HttpRequestBuilder.() -> Unit, + ): OfflineSubtitleFile? { + val format = offlineSubtitleFormat(subtitle.format) ?: return null + if (!isOfflineSubtitleFetchUrl(subtitle.fetchUrl)) { + Log.w(TAG, "skipping subtitle with unexpected reference id=$downloadId ordinal=$ordinal") + return null + } + val target = File(directory, "$ordinal.${offlineSubtitleExtension(format)}") + val partial = File(directory, "$ordinal.part") + return try { + if (!directory.isDirectory && !directory.mkdirs()) throw IOException("could not create $directory") + httpClient.prepareGet(subtitle.fetchUrl.trim()) { + configure() + // Embedded ASS/PGS sidecars are extracted from the source on + // demand, so the first byte can take a while; keep only an idle + // timeout, like the media transfer itself. + timeout { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = SUBTITLE_IDLE_TIMEOUT_MS + } + }.execute { response -> + if (response.status != HttpStatusCode.OK) { + throw IOException("HTTP ${response.status.value}") + } + var written = 0L + response.bodyAsChannel().toInputStream().use { input -> + partial.outputStream().use { output -> + val buffer = ByteArray(BUFFER_BYTES) + while (true) { + val read = input.read(buffer) + if (read < 0) break + written += read + if (written > MAX_SUBTITLE_BYTES) throw IOException("subtitle exceeds $MAX_SUBTITLE_BYTES bytes") + output.write(buffer, 0, read) + } + } + } + if (written == 0L) throw IOException("empty subtitle") + } + if (!partial.renameTo(target)) throw IOException("could not publish $target") + OfflineSubtitleFile( + path = target.absolutePath, + format = format, + language = subtitle.language.orNullIfBlank(), + title = subtitle.title.orNullIfBlank(), + forced = subtitle.forced, + hearingImpaired = subtitle.hearingImpaired, + ) + } catch (e: CancellationException) { + partial.delete() + throw e + } catch (e: Exception) { + Log.w(TAG, "subtitle fetch failed id=$downloadId ordinal=$ordinal format=$format", e) + partial.delete() + target.delete() + null + } + } + + companion object { + private const val TAG = "OfflineTrackAssets" + private const val MANIFEST_ATTEMPTS = 3 + private const val BUFFER_BYTES = 64 * 1024 + private const val SUBTITLE_IDLE_TIMEOUT_MS = 120_000L + + /** PGS tracks for a feature run to tens of MB; text is far smaller. */ + private const val MAX_SUBTITLE_BYTES = 256L * 1024 * 1024 + + /** Only video downloads have tracks to capture. */ + fun appliesTo(mediaType: String?): Boolean = + when (DownloadMediaType.fromWire(mediaType)) { + DownloadMediaType.Movie, DownloadMediaType.TvShow, DownloadMediaType.Unknown -> true + DownloadMediaType.Audiobook, DownloadMediaType.Ebook -> false + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt index 67d983f6b..1f26a2abc 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleManager.kt @@ -1712,9 +1712,17 @@ fun isSubtitleSelected(tracks: Tracks, identity: SubtitleIdentity): Boolean { ?.isTrackSelected(selection.trackIndex) == true } +/** + * Every text track in a snapshot that this device can render, in snapshot + * order, described the way subtitle resolution sees it. + */ +fun renderableMountedTextTracks(tracks: Tracks): List = + textTrackCandidates(tracks).filter(TextTrackCandidate::supported).map(TextTrackCandidate::track) + private data class TextTrackCandidate( val selection: SubtitleSelection, val track: MountedSubtitleTrack, + val supported: Boolean, ) private fun textTrackCandidates(tracks: Tracks): List { @@ -1735,6 +1743,7 @@ private fun textTrackCandidates(tracks: Tracks): List { forced = format.selectionFlags and C.SELECTION_FLAG_FORCED != 0, hearingImpaired = format.isHearingImpairedSubtitle(), ), + supported = group.isTrackSupported(trackIndex), ) flatIndex++ } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleMountResolver.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleMountResolver.kt index f3e85534c..60edfdb79 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleMountResolver.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/SubtitleMountResolver.kt @@ -20,6 +20,14 @@ private const val SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = "silo-subtitle:" fun subtitleArtifactTrackId(serverIndex: Int): String = "$SUBTITLE_ARTIFACT_TRACK_ID_PREFIX$serverIndex" +/** + * True for a Media3 track id Silo authored for a subtitle artifact (a + * [subtitleArtifactTrackId] or a downloaded-subtitle id), including one Media3 + * reports with a merged-source child prefix (`1:silo-subtitle:0`). + */ +fun isSubtitleArtifactTrackId(trackId: String?): Boolean = + trackId?.withoutMergedSourcePrefix().isReservedArtifactTrackId() + /** * True when a mounted Media3 `Format.id` denotes [expected]. * @@ -33,13 +41,14 @@ fun subtitleArtifactTrackId(serverIndex: Int): String = * Only a purely numeric prefix is accepted, so this can never collide with an * authored id that happens to contain a colon. */ -fun trackIdDenotes(actual: String?, expected: String): Boolean { - if (actual == null) return false - if (actual == expected) return true - val separator = actual.indexOf(':') - if (separator <= 0) return false - if (!actual.substring(0, separator).all(Char::isDigit)) return false - return actual.substring(separator + 1) == expected +fun trackIdDenotes(actual: String?, expected: String): Boolean = + actual != null && (actual == expected || actual.withoutMergedSourcePrefix() == expected) + +/** [trackIdDenotes]'s merged-source form: drops a purely numeric `:` prefix. */ +private fun String.withoutMergedSourcePrefix(): String { + val separator = indexOf(':') + if (separator <= 0 || !substring(0, separator).all(Char::isDigit)) return this + return substring(separator + 1) } /** diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt index c0b382df8..37f65f9fc 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt @@ -77,6 +77,11 @@ sealed interface AudioReconcileAction { * @param planAudioOrdinal the catalog ordinal the server says it delivered. * @param requiresMountedIdentity whether a byte-for-byte original-file plan * must prove the selected catalog row against Media3's mounted inventory. + * @param positionalCatalog whether [catalog] lists the mounted file's own audio + * tracks in file order (a downloaded file described by its offline manifest). + * The catalog ordinal then IS the Media3 audio-group ordinal, so selection + * goes by position: a re-encoded track (TrueHD source delivered as AAC) can + * never identity-match the source description, but it sits at the same place. */ fun reconcileDesiredAudioAction( desired: DesiredAudio?, @@ -86,6 +91,7 @@ fun reconcileDesiredAudioAction( selectedOrdinal: Int?, planAudioOrdinal: Int?, requiresMountedIdentity: Boolean = false, + positionalCatalog: Boolean = false, ): AudioReconcileAction { if (desired == null) return AudioReconcileAction.None // An empty or partial snapshot is not evidence of anything. The intent must @@ -102,6 +108,18 @@ fun reconcileDesiredAudioAction( val wanted = catalog.getOrNull(desired.catalogOrdinal) ?: return AudioReconcileAction.None + // Only trusted while the inventories agree in size: a snapshot that shows a + // different number of audio groups is not the file the catalog describes, + // and falls back to identity matching below. + if (positionalCatalog && mounted.size == catalog.size) { + val target = mounted[desired.catalogOrdinal] + return if (selectedOrdinal == target.ordinal) { + AudioReconcileAction.Confirm + } else { + AudioReconcileAction.Apply(target.ordinal) + } + } + // Resolved ONCE against the whole snapshot. Matching a one-element list // asks a different question: the matcher stops as soon as one candidate // remains, so a main mix and its commentary — same language, same codec — diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt index 6f4ffcdca..db0e94ae1 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/data/db/SiloDatabaseMigrationTest.kt @@ -120,6 +120,30 @@ class SiloDatabaseMigrationTest { } } + @Test + fun migration12To13KeepsDownloadRevisionsAndAddsNullableOfflineTracks() { + val name = "migration-12-to-13" + migrationHelper.createDatabase(name, 12).use { database -> + database.execSQL( + "INSERT INTO downloads (serverId, profileId, mediaFileId, recordId, contentId, title, mediaType, " + + "status, kind, fileSize, bytesSent, createdAt, updatedAtMs, revision) VALUES " + + "('s', 'p', 42, 'dl_1', 'mv_1', 'Example', 'movie', 'completed', 'queued', 1024, 1024, " + + "'2026-09-29T00:00:00Z', 123, 7)", + ) + } + migrationHelper.runMigrationsAndValidate(name, 13, true).use { database -> + database.query("SELECT recordId, status, bytesSent, revision, offlineTracksJson FROM downloads").use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("dl_1", cursor.getString(0)) + assertEquals("completed", cursor.getString(1)) + assertEquals(1024L, cursor.getLong(2)) + assertEquals(7, cursor.getInt(3)) + assertNull(cursor.getString(4)) + assertEquals(false, cursor.moveToNext()) + } + } + } + private companion object { const val DATABASE_NAME = "migration-7-to-8" } diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt new file mode 100644 index 000000000..44547a70c --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/DownloadSidecarOfflineTracksMappingTest.kt @@ -0,0 +1,53 @@ +package org.siloserver.silo.common.downloads + +import org.siloserver.silo.model.catalog.AudioTrack +import org.siloserver.silo.model.download.DownloadRecord +import org.siloserver.silo.model.download.DownloadSidecar +import org.siloserver.silo.model.download.OfflineSubtitleFile +import org.siloserver.silo.model.download.OfflineTrackInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DownloadSidecarOfflineTracksMappingTest { + + private val sidecar = DownloadSidecar( + record = DownloadRecord( + id = "dl_1", + contentId = "mv_1", + mediaFileId = 42, + kind = "queued", + status = "completed", + createdAt = "2026-09-29T00:00:00Z", + ), + title = "Example", + updatedAtMs = 1L, + ) + + @Test + fun offlineTracksSurviveTheRoomRow() { + val tracks = OfflineTrackInfo( + audioTracks = listOf( + AudioTrack(index = 0, language = "eng", codec = "aac", channels = 2, isDefault = true), + AudioTrack(index = 1, language = "jpn", codec = "aac", channels = 2), + ), + selectedAudioTrackIndex = 1, + subtitles = listOf( + OfflineSubtitleFile(path = "/data/subs/0.ass", format = "ass", language = "eng", forced = true), + ), + ) + + val restored = sidecar.copy(offlineTracks = tracks).toEntity("srv", "prof").toSidecar() + + assertEquals(tracks, restored.offlineTracks) + } + + @Test + fun rowsWithoutOfflineTracksStillLoad() { + val entity = sidecar.toEntity("srv", "prof") + + assertNull(entity.offlineTracksJson) + assertNull(entity.toSidecar().offlineTracks) + assertNull(entity.copy(offlineTracksJson = "{not json").toSidecar().offlineTracks) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt new file mode 100644 index 000000000..a41eb6c8a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/downloads/OfflineTrackAssetFetcherTest.kt @@ -0,0 +1,133 @@ +package org.siloserver.silo.common.downloads + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.defaultRequest +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflineTrackAssetFetcherTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val manifest = """ + { + "download_id": "dl_1", + "selected_audio_track_index": 1, + "audio_tracks": [ + {"index": 0, "language": "eng", "codec": "aac", "channels": 2, "default": true}, + {"index": 1, "language": "jpn", "codec": "aac", "channels": 2} + ], + "subtitles": [ + {"language": "eng", "format": "ass", "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:2"}, + {"language": "fre", "format": "srt", "external": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:0"}, + {"language": "eng", "format": "sup", "forced": true, "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:4"}, + {"language": "ger", "format": "sub", "fetch_url": "/api/v2/downloads/dl_1/subtitles/external:1"}, + {"language": "spa", "format": "srt", "fetch_url": "https://elsewhere.example/sub.srt"} + ] + } + """.trimIndent() + + private val requested = mutableListOf() + + private fun client( + manifestStatus: HttpStatusCode = HttpStatusCode.OK, + transientManifestFailures: Int = 0, + ) = HttpClient( + MockEngine { request -> + val path = request.url.encodedPath + requested += path + when { + path.endsWith("/manifest") && requested.count { it.endsWith("/manifest") } <= transientManifestFailures -> + respond("", HttpStatusCode.ServiceUnavailable) + path.endsWith("/manifest") -> respond(manifest, manifestStatus) + path.endsWith("/subtitles/embedded:2") -> respond("[Script Info]\nTitle: x\n") + // The server failed to read the external sidecar. + path.endsWith("/subtitles/external:0") -> respond("", HttpStatusCode.NotFound) + path.endsWith("/subtitles/embedded:4") -> respond(byteArrayOf(0x50, 0x47, 0x00, 0x01)) + else -> respond("", HttpStatusCode.InternalServerError) + } + }, + ) { + install(HttpTimeout) + defaultRequest { url("https://silo.example/") } + } + + @Test + fun capturesAudioTracksAndSavesEveryFetchableSidecar() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val info = assertNotNull( + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + + assertEquals(listOf("eng", "jpn"), info.audioTracks.map { it.language }) + assertEquals(1, info.defaultAudioPosition()) + // The failed SRT, the unmountable .sub and the foreign URL are skipped + // without failing the rest. + assertEquals(listOf("ass", "pgs"), info.subtitles.map { it.format }) + assertEquals(listOf(false, true), info.subtitles.map { it.forced }) + val ass = File(info.subtitles[0].path) + val pgs = File(info.subtitles[1].path) + assertTrue(ass.isFile && ass.name.endsWith(".ass")) + assertTrue(pgs.isFile && pgs.name.endsWith(".sup")) + assertEquals(storage.offlineSubtitleDirectory("srv", "prof", 42), ass.parentFile) + assertFalse(requested.any { it.contains("sub.srt") }) + assertTrue(ass.parentFile!!.listFiles()!!.none { it.name.endsWith(".part") }) + } + + @Test + fun deletingTheDownloadRemovesItsSidecars() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + val info = assertNotNull( + OfflineTrackAssetFetcher(client(), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + + storage.delete("srv", "prof", 42) + + assertTrue(info.subtitles.none { File(it.path).exists() }) + } + + @Test + fun aMissingManifestCapturesNothing() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + assertNull( + OfflineTrackAssetFetcher(client(HttpStatusCode.NotFound), storage).fetch("dl_1", "srv", "prof", 42) {}, + ) + assertEquals(listOf("/api/v2/downloads/dl_1/manifest"), requested) + } + + @Test + fun aTransientManifestFailureIsRetried() = runBlocking { + val storage = DownloadStorage(tmp.newFolder("filesDir")) + + val info = assertNotNull( + OfflineTrackAssetFetcher(client(transientManifestFailures = 2), storage, manifestRetryDelayMs = 0) + .fetch("dl_1", "srv", "prof", 42) {}, + ) + + assertEquals(3, requested.count { it.endsWith("/manifest") }) + assertEquals(2, info.audioTracks.size) + } + + @Test + fun onlyVideoDownloadsCaptureTracks() { + assertTrue(OfflineTrackAssetFetcher.appliesTo("movie")) + assertTrue(OfflineTrackAssetFetcher.appliesTo("tv")) + assertFalse(OfflineTrackAssetFetcher.appliesTo("audiobook")) + assertFalse(OfflineTrackAssetFetcher.appliesTo("ebook")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerTrackSelectionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerTrackSelectionTest.kt index e35cae9f9..2af8161e6 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerTrackSelectionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/SubtitleManagerTrackSelectionTest.kt @@ -609,6 +609,27 @@ class SubtitleManagerTrackSelectionTest { } } + @Test + fun renderableTextTracksDropUnsupportedTracksAndReportTheCueSourceFormat() { + fun group(format: Format, support: Int) = + Tracks.Group(TrackGroup(format), false, intArrayOf(support), booleanArrayOf(false)) + val tracks = Tracks( + listOf( + group( + subtitle("English", "en", MimeTypes.APPLICATION_MEDIA3_CUES, MimeTypes.TEXT_SSA, id = "0:3", forced = true), + C.FORMAT_HANDLED, + ), + group(subtitle(null, "it", MimeTypes.APPLICATION_TX3G, id = "0:4"), C.FORMAT_UNSUPPORTED_TYPE), + ), + ) + + val mounted = renderableMountedTextTracks(tracks).single() + + assertEquals("0:3", mounted.trackId) + assertEquals(MimeTypes.TEXT_SSA, mounted.codec) + assertEquals(true, mounted.forced) + } + private fun subtitle( label: String?, language: String?, diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/AudioReconcilePositionalTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/AudioReconcilePositionalTest.kt new file mode 100644 index 000000000..f948a907f --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/AudioReconcilePositionalTest.kt @@ -0,0 +1,94 @@ +package org.siloserver.silo.common.player.video + +import org.siloserver.silo.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Downloaded files described by their offline manifest: the catalog lists the + * file's own audio tracks in file order, so selection goes by position. + */ +class AudioReconcilePositionalTest { + + /** Two English stereo AAC tracks that only a title could tell apart. */ + private val catalog = listOf( + AudioTrack(index = 0, codec = "aac", channels = 2, language = "eng", title = "Main"), + AudioTrack(index = 1, codec = "aac", channels = 2, language = "jpn"), + AudioTrack(index = 2, codec = "aac", channels = 2, language = "eng", title = "Commentary"), + ) + + /** Media3 reports no labels for the MP4's tracks. */ + private val mounted = listOf( + MountedAudioTrack(0, "eng", "audio/mp4a-latm", 2), + MountedAudioTrack(1, "jpn", "audio/mp4a-latm", 2), + MountedAudioTrack(2, "eng", "audio/mp4a-latm", 2), + ) + + private fun desire(ordinal: Int) = DesiredAudio( + generation = 1L, + catalogOrdinal = ordinal, + explicit = true, + fileId = 7, + ) + + private fun reconcile( + ordinal: Int, + selectedOrdinal: Int?, + mountedTracks: List = mounted, + catalogTracks: List = catalog, + positional: Boolean = true, + ) = reconcileDesiredAudioAction( + desired = desire(ordinal), + activeFileId = 7, + catalog = catalogTracks, + mounted = mountedTracks, + selectedOrdinal = selectedOrdinal, + planAudioOrdinal = null, + positionalCatalog = positional, + ) + + @Test + fun selectsTheMountedGroupAtTheSamePosition() { + assertEquals(AudioReconcileAction.Apply(2), reconcile(ordinal = 2, selectedOrdinal = 0)) + assertEquals(AudioReconcileAction.Confirm, reconcile(ordinal = 2, selectedOrdinal = 2)) + } + + @Test + fun identityMatchingCannotTellTheSameLanguageTracksApart() { + // Without the positional mode the untitled mount is ambiguous, so the + // pick silently goes nowhere: the failure this mode exists for. + assertEquals( + AudioReconcileAction.None, + reconcile(ordinal = 2, selectedOrdinal = 0, positional = false), + ) + } + + @Test + fun reEncodedTracksAreSelectedEvenWhenTheCatalogNamesTheSourceCodec() { + val sourceDescribed = listOf( + AudioTrack(index = 0, codec = "truehd", channels = 8, language = "eng"), + AudioTrack(index = 1, codec = "ac3", channels = 6, language = "fre"), + ) + val remux = listOf( + MountedAudioTrack(0, "eng", "audio/mp4a-latm", 2), + MountedAudioTrack(1, "fre", "audio/ac3", 6), + ) + + assertEquals( + AudioReconcileAction.Apply(0), + reconcile(ordinal = 0, selectedOrdinal = 1, mountedTracks = remux, catalogTracks = sourceDescribed), + ) + } + + @Test + fun aSnapshotOfADifferentSizeFallsBackToIdentityMatching() { + val legacySingleTrack = listOf(MountedAudioTrack(0, "jpn", "audio/mp4a-latm", 2)) + + // Identity matching finds the Japanese track even though the file does + // not have the three tracks the catalog lists. + assertEquals( + AudioReconcileAction.Confirm, + reconcile(ordinal = 1, selectedOrdinal = 0, mountedTracks = legacySingleTrack), + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 8df2f18c3..ff509080c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -386,6 +386,7 @@ val androidModule = module { sectionRepository = get(), castPlaybackPreparer = get(), seekIntervalStore = get(), + activeProfileStore = get(), ) } viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt index d10c875f3..13a724a49 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/downloads/DownloadsViewModel.kt @@ -526,10 +526,9 @@ class DownloadsViewModel( // write a DURABLE tombstone (so the record can't resurrect as a ghost on // the next online refresh — written BEFORE byte deletion so a crash can't // lose the server-delete intent), then drop the bytes + metadata. - val status = record?.statusEnum() ?: sidecar?.record?.statusEnum() - if (status == DownloadStatus.Queued || status == DownloadStatus.Downloading) { - downloadEnqueuer.cancel(id) - } + // Completed media can still be capturing subtitle sidecars. Wait + // for that worker too, before removing files or its metadata row. + downloadEnqueuer.cancel(id) // Drop the in-memory sidecar maps BEFORE the tombstone: enqueueDurableDelete // emits on repository.records and the collector rebuilds sections from // metadataByRecordId — if the entry were still present the deleted row would diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracks.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracks.kt new file mode 100644 index 000000000..94ee0b6bf --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracks.kt @@ -0,0 +1,93 @@ +package org.siloserver.silo.android.ui.screens.player + +import org.siloserver.silo.common.player.MountedSubtitleTrack +import org.siloserver.silo.common.player.isSubtitleArtifactTrackId +import org.siloserver.silo.common.player.subtitleArtifactTrackId +import org.siloserver.silo.model.download.OfflineSubtitleFile +import org.siloserver.silo.model.playback.PlayerSubtitleInfo +import org.siloserver.silo.playback.canonicalSubtitleCodecFamily +import org.siloserver.silo.playback.isBitmapSubtitleCodecFamily +import org.siloserver.silo.playback.isClientMountableBitmapCodecFamily + +/** + * Menu indexes for text tracks found inside a downloaded file. Kept clear of + * the sidecar rows' indexes (0 until sidecar count), which also key the + * sidecars' Media3 track ids (`silo-subtitle:`). + */ +internal const val OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE = 10_000 + +/** Subtitle preferences applied to offline playback, as online playback resolves them. */ +internal data class OfflineSubtitlePreferences( + val preferredLanguage: String?, + val mode: String?, + val showForced: Boolean, +) + +/** + * Menu rows for the subtitle sidecars saved with a download. Each row's + * [PlayerSubtitleInfo.index] is its position here, which the mount turns into + * the sidecar's Media3 track id, so selection resolves by that exact id. + * Files that have gone missing are left out. + */ +internal fun offlineSidecarSubtitleRows( + files: List, + fileExists: (String) -> Boolean = { java.io.File(it).isFile }, +): List = + files.filter { fileExists(it.path) }.mapIndexed { index, file -> + PlayerSubtitleInfo( + index = index, + language = file.language, + codec = file.format, + label = file.title ?: if (file.hearingImpaired) "SDH" else null, + source = "external", + forced = file.forced, + url = "file://" + java.io.File(file.path).toURI().rawPath, + // The id the mount gives this sidecar; also keeps two otherwise + // identical sidecars distinct when the menu resolves a selection. + mediaTrackId = subtitleArtifactTrackId(index), + ) + } + +/** + * Menu rows for the text tracks inside the downloaded file itself (MP4 timed + * text in a prepared download; any embedded track of an original download). + * + * The file's own default flag is deliberately not carried: a prepared MP4 + * marks its first subtitle track default whether or not it should show, so + * only the forced flag and the viewer's preferences may turn one on. + * + * [tracks] are the renderable ones (renderableMountedTextTracks). Merged + * sidecars and bitmap families the player cannot mount are left out. + */ +internal fun localEmbeddedSubtitleRows(tracks: List): List = + tracks + .filter { track -> + val family = canonicalSubtitleCodecFamily(track.codec) + track.trackId != null && + !isSubtitleArtifactTrackId(track.trackId) && + (!isBitmapSubtitleCodecFamily(family) || isClientMountableBitmapCodecFamily(family)) + } + .mapIndexed { ordinal, track -> + PlayerSubtitleInfo( + index = OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE + ordinal, + language = track.language, + codec = track.codec, + label = track.label, + source = "embedded", + forced = track.forced, + url = "", + mediaTrackId = track.trackId, + ) + } + +/** A row [localEmbeddedSubtitleRows] produced. */ +internal fun PlayerSubtitleInfo.isLocalEmbeddedSubtitleRow(): Boolean = + source == "embedded" && url.isBlank() && index >= OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE + +/** + * A row that exists only in offline playback: a track inside the downloaded + * file or a sidecar saved on the device. Its index is a local menu position, + * never a server subtitle index. + */ +internal fun PlayerSubtitleInfo.isOfflineLocalSubtitleRow(): Boolean = + isLocalEmbeddedSubtitleRow() || url.startsWith("file://") diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt index 5a0cebe37..ba382e736 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt @@ -98,6 +98,7 @@ import org.siloserver.silo.common.player.video.PlaybackStartupStallDetector import org.siloserver.silo.common.player.video.PlaybackRuntimeCorrectionMetrics import org.siloserver.silo.common.player.video.PostResumeVideoStallDetector import org.siloserver.silo.common.player.isSubtitleSelected +import org.siloserver.silo.common.player.renderableMountedTextTracks import org.siloserver.silo.common.player.video.VideoPlayerTrackEntry import org.siloserver.silo.model.playback.PlaybackExecutionPlan import org.siloserver.silo.model.playback.PlayerSubtitleInfo @@ -1039,6 +1040,14 @@ fun PlayerScreen( val backend = videoBackend ?: return if (!viewModel.isCurrentSubtitleMount(mount)) return val currentTracks = backend.player.currentTracks + // A downloaded file's own text tracks are only known now; + // this completes its subtitle menu and runs auto-selection + // before the selection below reads the live state. + viewModel.onLocalMediaTracksChanged( + mediaId = backend.player.currentMediaItem?.mediaId, + textTracks = renderableMountedTextTracks(currentTracks), + hasTracks = !currentTracks.isEmpty, + ) val liveState = viewModel.uiState.value val pendingIdentity = liveState.localSubtitleMountIdentity val targetIdentity = pendingIdentity ?: liveState.committedSubtitleIdentity diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt index 87af85cde..eff41a530 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt @@ -85,6 +85,7 @@ import org.siloserver.silo.model.subtitles.SubtitleSearchRequest import org.siloserver.silo.model.subtitles.SubtitleTranslateRequest import org.siloserver.silo.network.ApiResult import org.siloserver.silo.common.player.AutoPlayGuard +import org.siloserver.silo.common.player.MountedSubtitleTrack import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.errorMessage import org.siloserver.silo.playback.audioTrackFingerprint @@ -135,6 +136,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.siloserver.silo.playback.orNullIfBlank import java.util.concurrent.atomic.AtomicBoolean /** @@ -157,7 +159,11 @@ internal fun selectedServerSubtitleTrackIndex( subtitleTracks: List, ): Int? = when (selectedOrdinal) { -1 -> -1 - else -> subtitleTracks.getOrNull(selectedOrdinal)?.index + // A downloaded file's own tracks and saved sidecars carry local menu + // indexes that name no server subtitle; let the server apply preferences. + else -> subtitleTracks.getOrNull(selectedOrdinal) + ?.takeUnless { it.isOfflineLocalSubtitleRow() } + ?.index } data class PlaybackClock( @@ -226,8 +232,12 @@ internal fun PlayerViewModel.PlayerUiState.withPlaybackClock(clock: PlaybackCloc internal fun mobileAudioTrackPersistenceUpdate( committedAudioTrackIndex: Int?, audioTracks: List, + originalDownload: Boolean = false, ): TrackSelectionFingerprintUpdate = committedAudioTrackIndex ?.let(audioTracks::getOrNull) + // Offline manifests number audio rows by position. The online catalog + // omits this field, so its source-track fingerprints use the default 0. + ?.let { if (originalDownload) it.copy(index = 0) else it } ?.let(::audioTrackFingerprint) ?.let(TrackSelectionFingerprintUpdate::Set) ?: TrackSelectionFingerprintUpdate.Preserve @@ -284,6 +294,9 @@ class PlayerViewModel( // Profile-wide seek intervals (settings revision 9). Optional so unit // tests that construct the VM directly keep the legacy fixed intervals. private val seekIntervalStore: org.siloserver.silo.common.settings.SeekIntervalStore? = null, + // Cached active profile; offline playback reads subtitle preferences from + // it instead of waiting on the server. Optional for the same reason. + private val activeProfileStore: org.siloserver.silo.model.profile.ActiveProfileStore? = null, ) : ViewModel() { // Last load request, replayed by the "Can't reach server" Retry / Try Anyway. @@ -462,6 +475,12 @@ class PlayerViewModel( * current position. */ val subtitleRefreshNonce: Int = 0, + /** + * True when [audioTracks] come from a download's offline manifest and + * list the local file's own audio tracks in file order, so an audio + * choice selects the Media3 audio group at the same position. + */ + val offlineAudioByPosition: Boolean = false, // Live player statistics for phone diagnostics. Populates field-by-field // as PlaybackAnalyticsListener emits decoder, format, bandwidth, and // dropped-frame events. @@ -973,8 +992,10 @@ class PlayerViewModel( upNextVideoEnded = it.upNextVideoEnded && preservesNextUp, upNextCountdownSeconds = null, stats = PlayerStatsSnapshot(), + offlineAudioByPosition = false, ) } + localSubtitleState = null } private var browseLibraryId: Int? = null @@ -1444,6 +1465,7 @@ class PlayerViewModel( subtitleRefreshNonce = 0, preferredAudioLanguage = playbackState.preferredAudioLanguage, preferredTextLanguage = playbackState.preferredTextLanguage, + offlineAudioByPosition = false, ) } @@ -3141,7 +3163,10 @@ class PlayerViewModel( qualityPreference = currentMobileQualityPreference(), subtitleTracks = state.subtitleTracks, audioTracks = state.audioTracks, - writeScope = finalPositionScope, + // A download's subtitle rows describe the local file, not the + // server's inventory. Saved as the item's preference they would not + // resolve online and would suppress auto-selection there. + writeScope = finalPositionScope.takeUnless { state.isLocalFilePlayback() }, ) private fun currentMobileQualityPreference(): String? = @@ -3320,6 +3345,8 @@ class PlayerViewModel( fun onSelectSubtitle(index: Int) { val state = _uiState.value if (index != -1 && index !in state.subtitleTracks.indices) return + // The viewer chose: a later local track discovery must not auto-select over it. + localSubtitleState = localSubtitleState?.copy(autoSelectionPending = false) val identity = state.subtitleTracks .getOrNull(index) ?.let(::mobileSubtitleIdentity) @@ -3371,6 +3398,10 @@ class PlayerViewModel( ) } setDesiredAudio(serverIndex, explicit = userInitiated) + // A downloaded file is switched on the player only: there is no + // server session to replan, and the desired-audio reconcile above + // already applies the track when the file carries it. + if (state.isLocalFilePlayback()) return // Already in the mounted stream: switch it on the player instead of // rebuilding the session to deliver audio already being received. A // replan is only needed when the track is genuinely absent. @@ -3443,6 +3474,7 @@ class PlayerViewModel( selectedOrdinal = selectedOrdinal, planAudioOrdinal = state.playbackPlan?.selectedTracks?.audioIndex, requiresMountedIdentity = requiresMountedIdentity, + positionalCatalog = state.offlineAudioByPosition, ) ) { AudioReconcileAction.None -> armOriginalAudioSelectionVerification( @@ -3484,7 +3516,9 @@ class PlayerViewModel( localAudioAttemptsFor(desired.generation) >= MAX_LOCAL_AUDIO_ATTEMPTS ) { _pendingLocalAudioSelection.value = null - replanForDesiredAudio(desired) + // A downloaded file has no server plan to change; the + // player's own tracks are all there is. + if (!state.isLocalFilePlayback()) replanForDesiredAudio(desired) return } localAudioAttempt += 1 @@ -3600,8 +3634,11 @@ class PlayerViewModel( private fun persistDesiredAudio(catalogOrdinal: Int) { val state = _uiState.value + // A prepared download's rows describe its own re-encoded tracks, whose + // fingerprints would not match the source's tracks online. + if (state.offlineAudioByPosition) return val context = mobileSubtitleContext(state) - val scope = context.writeScope ?: return + val scope = finalPositionScope ?: return viewModelScope.launch { runCatching { userItemStatePort.recordTrackSelection( @@ -3611,6 +3648,7 @@ class PlayerViewModel( audioUpdate = mobileAudioTrackPersistenceUpdate( committedAudioTrackIndex = catalogOrdinal, audioTracks = context.audioTracks, + originalDownload = state.isLocalFilePlayback(), ), // Untouched: this path changed audio only. subtitleUpdate = TrackSelectionFingerprintUpdate.Preserve, @@ -4613,6 +4651,30 @@ class PlayerViewModel( ?: watchDetail?.posterUrl?.takeIf { url -> url.isNotBlank() } ?: sidecar.posterUrl?.takeIf { url -> url.isNotBlank() } + // Downloads that captured their offline manifest describe the local + // file's own audio tracks (positions in the file) and carry saved + // subtitle sidecars. Older downloads keep the catalog audio rows and + // have no sidecars. + val offlineTracks = sidecar.offlineTracks + val offlineAudioTracks = offlineTracks?.audioTracks.orEmpty() + val offlineAudioByPosition = offlineTracks?.audioByPosition == true && offlineAudioTracks.isNotEmpty() + val localAudioTracks = offlineAudioTracks.ifEmpty { versions[selectedIndex].audioTracks.orEmpty() } + // The selected version carries the same rows: cast, replans and the + // subtitle context read audio indexes from it. + val localVersions = if (offlineAudioTracks.isNotEmpty()) { + versions.mapIndexed { index, version -> + if (index == selectedIndex) version.copy(audioTracks = offlineAudioTracks) else version + } + } else { + versions + } + val localSelectedAudio = offlineTracks?.defaultAudioPosition() ?: 0 + val sidecarSubtitles = withContext(Dispatchers.IO) { + offlineSidecarSubtitleRows(offlineTracks?.subtitles.orEmpty()) + } + val subtitlePreferences = offlineSubtitlePreferences(watchDetail) + if (!ownsLoad(loadOwner)) return false + val published = loadOwners.runIfOwned(loadOwner) { val mountGeneration = expectNextMediaMount() val preservesNextUp = nextUpTransitionGate.expectMount( @@ -4650,12 +4712,20 @@ class PlayerViewModel( isPlaying = true, isPaused = false, isBuffering = false, - versions = versions, + versions = localVersions, selectedVersionIndex = selectedIndex, - audioTracks = versions[selectedIndex].audioTracks ?: emptyList(), - subtitleTracks = emptyList(), // sidecars are remote in v1 - selectedAudioIndex = 0, + audioTracks = localAudioTracks, + // Saved sidecars mount with the file. Text tracks inside the + // file join the list once Media3 reports them + // (onLocalMediaTracksChanged). + subtitleTracks = sidecarSubtitles, + selectedAudioIndex = localSelectedAudio, selectedSubtitleIndex = -1, + committedSubtitleIdentity = SubtitleIdentity.Off, + pendingSubtitleIdentity = null, + localSubtitleMountIdentity = null, + subtitleApplying = false, + offlineAudioByPosition = offlineAudioByPosition, intro = watchDetail?.intro, credits = watchDetail?.credits, recap = watchDetail?.recap, @@ -4674,17 +4744,28 @@ class PlayerViewModel( subtitleRefreshNonce = 0, ) } + localSubtitleState = LocalSubtitleState( + contentId = contentId, + mountGeneration = mountGeneration, + preferences = subtitlePreferences, + ) + mobileSubtitleTransactions.resetContent( + context = mobileSubtitleContext(_uiState.value), + committedIdentity = SubtitleIdentity.Off, + ) Log.i( TAG, - "tryLocalPlayback: serving ${media.displayName} (${media.sizeBytes}B) for content=$contentId (sidecar id=${sidecar.record.id})", + "tryLocalPlayback: serving ${media.displayName} (${media.sizeBytes}B) for content=$contentId " + + "(sidecar id=${sidecar.record.id}) offlineAudio=${offlineAudioTracks.size} " + + "sidecarSubtitles=${sidecarSubtitles.size}", ) } - // Downloaded playback publishes the catalog and hardcodes ordinal 0, but - // Media3 still picks its own default from the file's tracks -- so the - // intent has to exist here too or a multi-audio download cannot be - // corrected. - if (_uiState.value.audioTracks.isNotEmpty()) setDesiredAudio(0, explicit = false) + // Media3 picks its own default from the file's tracks, so the intent + // has to exist here too or a multi-audio download cannot be corrected. + // With manifest data the default is the manifest's pick (the viewer's + // preferred language when the file has it); legacy downloads keep 0. + if (_uiState.value.audioTracks.isNotEmpty()) setDesiredAudio(localSelectedAudio, explicit = false) return published } @@ -4721,6 +4802,111 @@ class PlayerViewModel( super.onCleared() } + // ---- Local (downloaded) subtitle discovery --------------------------------- + + /** + * Subtitle state of the downloaded file being played: the preferences to + * auto-select with, the file's own text tracks last listed, and whether auto-selection still + * has to run. Text tracks inside the file are only known once Media3 has + * parsed it, so the menu is completed, and auto-selection runs, from the + * first track snapshot of this mount. + */ + private data class LocalSubtitleState( + val contentId: String, + val mountGeneration: Long, + val preferences: OfflineSubtitlePreferences, + val embeddedTracks: List? = null, + val autoSelectionPending: Boolean = true, + ) + + private var localSubtitleState: LocalSubtitleState? = null + + /** + * Called by PlayerScreen with every Media3 track snapshot of the current + * mount. Only acts for a downloaded file this ViewModel mounted: adds the + * file's own text tracks to the subtitle menu and runs the one-time + * preference-based subtitle auto-selection. + */ + internal fun onLocalMediaTracksChanged( + mediaId: String?, + textTracks: List, + hasTracks: Boolean, + ) { + val local = localSubtitleState ?: return + val state = _uiState.value + if (!state.isLocalFilePlayback()) return + if (state.contentId != local.contentId || state.mediaMountGeneration != local.mountGeneration) return + if (mediaId != null && mediaId != local.contentId) return + // An empty snapshot is the gap between mounts, not a file without tracks. + if (!hasTracks) return + + var current = local + if (current.embeddedTracks != textTracks) { + val embedded = localEmbeddedSubtitleRows(textTracks) + _uiState.update { + // Replace only the rows for the file's own tracks; the saved + // sidecars (and anything else already listed) stay as they are. + val rows = embedded + it.subtitleTracks.filterNot(PlayerSubtitleInfo::isLocalEmbeddedSubtitleRow) + it.copy( + subtitleTracks = rows, + selectedSubtitleIndex = resolveMobileSubtitleOrdinal(it.committedSubtitleIdentity, rows) ?: -1, + ) + } + current = current.copy(embeddedTracks = textTracks) + localSubtitleState = current + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(_uiState.value)) + } + if (!current.autoSelectionPending) return + localSubtitleState = current.copy(autoSelectionPending = false) + + val published = _uiState.value + val selection = resolveMobileAutoSubtitleSelection( + audioTracks = published.audioTracks, + selectedAudioIndex = published.selectedAudioIndex, + subtitles = published.subtitleTracks, + preferredLanguage = current.preferences.preferredLanguage, + subtitleMode = current.preferences.mode, + showForcedSubtitles = current.preferences.showForced, + ) + val identity = when (selection) { + is MobileSubtitleAutoSelection.Select -> + published.subtitleTracks.getOrNull(selection.ordinal)?.let(::mobileSubtitleIdentity) + MobileSubtitleAutoSelection.Disable, + MobileSubtitleAutoSelection.NoChange, + -> null + } ?: return + if (identity == published.committedSubtitleIdentity) return + Log.i(TAG, "local subtitle auto-selection applied") + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(published)) + mobileSubtitleTransactions.select(identity) + } + + /** + * Subtitle preferences for offline playback, resolved the way the online + * starter does: the server's effective values for this item when the + * detail could be read, then the cached active profile, then the + * defaults. Nothing here waits on the server, so an unreachable one cannot + * delay the local file. + */ + private suspend fun offlineSubtitlePreferences( + watchDetail: org.siloserver.silo.model.catalog.WatchDetail?, + ): OfflineSubtitlePreferences { + val language = watchDetail?.effectiveSubtitleLanguage.orNullIfBlank() + val mode = watchDetail?.effectiveSubtitleMode.orNullIfBlank() + val forced = watchDetail?.effectiveShowForcedSubtitles + val profile = if (language == null || mode == null || forced == null) { + val activeId = profileRepository.getActiveProfileId() + activeProfileStore?.activeProfile?.value?.takeIf { it.id == activeId } + } else { + null + } + return OfflineSubtitlePreferences( + preferredLanguage = language ?: profile?.subtitleLanguage.orNullIfBlank(), + mode = mode ?: profile?.subtitleMode.orNullIfBlank(), + showForced = forced ?: profile?.showForcedSubtitles ?: true, + ) + } + private suspend fun resolveDownloadScope(): Pair { val serverId = serverRegistry.activeServerId.value ?: DownloadEnqueuer.DEFAULT_SERVER_ID val profileId = profileRepository.getActiveProfileId() ?: DownloadEnqueuer.DEFAULT_PROFILE_ID @@ -4738,6 +4924,16 @@ private const val PLAYBACK_PAUSE_GRACE_MS = 1_500L /** Snapshots to let a local audio switch take before asking the server. */ private const val MAX_LOCAL_AUDIO_ATTEMPTS = 3 +/** + * A downloaded file mounted by the offline-first path: a local URI with no + * server session or playback plan behind it. + */ +internal fun PlayerViewModel.PlayerUiState.isLocalFilePlayback(): Boolean { + val url = streamUrl ?: return false + return sessionId == null && playbackPlan == null && + (url.startsWith("file://") || url.startsWith("content://")) +} + internal fun authoritativePlaybackSubtitleOrdinal( serverIndex: Int?, playbackTracks: List, diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt index 7156f87a0..57b1212e4 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModel.kt @@ -143,6 +143,13 @@ class SettingsViewModel( private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var subtitleLanguageEditGeneration = 0L + private var subtitleModeEditGeneration = 0L + private var forcedSubtitlesEditGeneration = 0L + private var subtitleLanguageConfirmedGeneration = 0L + private var subtitleModeConfirmedGeneration = 0L + private var forcedSubtitlesConfirmedGeneration = 0L + /** Profile-wide video and audiobook skip intervals (settings revision 9). */ val seekIntervals = SeekIntervalSettingsModel(seekIntervalStore, audiobookSettingsStore, viewModelScope) @@ -557,8 +564,14 @@ class SettingsViewModel( } } + // The subtitle setters below also store each write's confirmed value in + // the cached profile, which offline playback reads for its subtitle + // preferences. Only a newer successful write to the same field supersedes + // a confirmed value; pending or failed edits leave that value available. + /** [language] is a BCP 47 tag, or "" for off. */ fun setSubtitleLanguage(language: String) { + val editGeneration = ++subtitleLanguageEditGeneration val previous = _uiState.value.subtitleLanguage _uiState.update { it.copy(subtitleLanguage = language) } viewModelScope.launch { @@ -568,12 +581,18 @@ class SettingsViewModel( if (it.subtitleLanguage == language) it.copy(subtitleLanguage = previous) else it } } else { - applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + if (editGeneration > subtitleLanguageConfirmedGeneration) { + applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + subtitleLanguageConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.subtitleLanguage ?: language + activeProfileStore.update { it.copy(subtitleLanguage = confirmed) } + } } } } fun setSubtitleMode(mode: SubtitleMode) { + val editGeneration = ++subtitleModeEditGeneration val previous = _uiState.value.subtitleMode _uiState.update { it.copy(subtitleMode = mode) } viewModelScope.launch { @@ -583,12 +602,18 @@ class SettingsViewModel( if (it.subtitleMode == mode) it.copy(subtitleMode = previous) else it } } else { - applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + if (editGeneration > subtitleModeConfirmedGeneration) { + applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + subtitleModeConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.subtitleMode ?: mode.wire + activeProfileStore.update { it.copy(subtitleMode = confirmed) } + } } } } fun setShowForcedSubtitles(enabled: Boolean) { + val editGeneration = ++forcedSubtitlesEditGeneration val previous = _uiState.value.showForcedSubtitles _uiState.update { it.copy(showForcedSubtitles = enabled) } viewModelScope.launch { @@ -598,8 +623,13 @@ class SettingsViewModel( if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it } } else { - applyResolved(result.snapshot, edited = enabled.toString()) { - it.showForcedSubtitles.toString() + if (editGeneration > forcedSubtitlesConfirmedGeneration) { + applyResolved(result.snapshot, edited = enabled.toString()) { + it.showForcedSubtitles.toString() + } + forcedSubtitlesConfirmedGeneration = editGeneration + val confirmed = result.snapshot?.showForcedSubtitles ?: enabled + activeProfileStore.update { it.copy(showForcedSubtitles = confirmed) } } } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt index 68f8c5476..b550919fd 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt @@ -1,10 +1,13 @@ package org.siloserver.silo.android.ui.screens.player +import kotlinx.serialization.json.Json import org.siloserver.silo.model.catalog.AudioTrack import org.siloserver.silo.playback.audioTrackFingerprint +import org.siloserver.silo.playback.resolveAudioTrackOrdinal import org.siloserver.silo.repository.port.TrackSelectionFingerprintUpdate import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull /** @@ -62,6 +65,22 @@ class MobileAudioTrackSelectionTest { ) } + @Test + fun `an original download choice restores against the online catalog`() { + val catalog = Json.decodeFromString>( + """[{"language":"eng","codec":"aac","title":"English"},{"language":"deu","codec":"aac","title":"German"}]""", + ) + val manifestTracks = catalog.mapIndexed { ordinal, track -> track.copy(index = ordinal) } + val update = assertIs(mobileAudioTrackPersistenceUpdate( + committedAudioTrackIndex = 1, + audioTracks = manifestTracks, + originalDownload = true, + )) + + assertEquals(1, resolveAudioTrackOrdinal(catalog, update.fingerprint)) + assertEquals(1, selectedServerAudioTrackIndex(1, manifestTracks)) + } + @Test fun `no committed audio preserves whatever was stored`() { assertEquals( diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracksTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracksTest.kt new file mode 100644 index 000000000..a912ed085 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/OfflinePlaybackTracksTest.kt @@ -0,0 +1,117 @@ +package org.siloserver.silo.android.ui.screens.player + +import org.siloserver.silo.common.player.MountedSubtitleTrack +import org.siloserver.silo.common.player.resolveMountedSubtitle +import org.siloserver.silo.model.catalog.AudioTrack +import org.siloserver.silo.model.download.OfflineSubtitleFile +import org.siloserver.silo.model.playback.SubtitleIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflinePlaybackTracksTest { + + private val files = listOf( + OfflineSubtitleFile(path = "/data/app/subs/0.ass", format = "ass", language = "eng", title = "Signs & Songs"), + OfflineSubtitleFile(path = "/data/app/subs/1.sup", format = "pgs", language = "eng", forced = true), + OfflineSubtitleFile(path = "/data/app/subs/2.srt", format = "srt", language = "fre", hearingImpaired = true), + ) + + /** A prepared MP4's timed text, the two merged sidecars, and a bitmap track the menu drops. */ + private val mediaTracks = listOf( + textTrack(0, "0:3", null, "eng", "application/x-quicktime-tx3g"), + textTrack(1, "0:4", null, "spa", "application/x-quicktime-tx3g"), + textTrack(2, "1:silo-subtitle:0", "English", "eng", "text/x-ssa"), + textTrack(3, "2:silo-subtitle:1", "English", "eng", "application/pgs", forced = true), + textTrack(4, "0:5", null, "ger", "application/vobsub"), + ) + + @Test + fun sidecarRowsSkipMissingFilesAndPointAtTheLocalFile() { + val rows = offlineSidecarSubtitleRows(files) { !it.endsWith("1.sup") } + + assertEquals(listOf(0, 1), rows.map { it.index }) + assertEquals(listOf("eng", "fre"), rows.map { it.language }) + assertEquals("file:///data/app/subs/0.ass", rows[0].url) + assertEquals("Signs & Songs", rows[0].label) + assertEquals("SDH", rows[1].label) + } + + @Test + fun offlineLocalRowsNeverBecomeServerSubtitleIndexes() { + val rows = offlineSidecarSubtitleRows(files) { true } + localEmbeddedSubtitleRows(mediaTracks) + + // Cast and replans send this index to the server; a local menu position + // names no server subtitle, so the server applies preferences instead. + rows.indices.forEach { ordinal -> assertNull(selectedServerSubtitleTrackIndex(ordinal, rows)) } + assertEquals(-1, selectedServerSubtitleTrackIndex(-1, rows)) + } + + @Test + fun sidecarSelectionResolvesToItsMergedMedia3Track() { + val rows = offlineSidecarSubtitleRows(files) { true } + + val identity = assertIs(mobileSubtitleIdentity(rows[1])) + assertEquals(1, identity.serverIndex) + val match = assertNotNull(resolveMountedSubtitle(identity, mediaTracks)) + assertEquals("2:silo-subtitle:1", match.track.trackId) + } + + @Test + fun embeddedRowsListOnlyTheFilesOwnRenderableTracks() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + + assertEquals(listOf("0:3", "0:4"), rows.map { it.mediaTrackId }) + assertTrue(rows.all { it.url.isBlank() && it.source == "embedded" }) + assertTrue(rows.all { it.index >= OFFLINE_EMBEDDED_SUBTITLE_INDEX_BASE }) + val identity = assertIs(mobileSubtitleIdentity(rows[1])) + val match = assertNotNull(resolveMountedSubtitle(identity, mediaTracks)) + assertEquals("0:4", match.track.trackId) + } + + @Test + fun menuSelectionResolvesBackToTheSameRow() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + offlineSidecarSubtitleRows(files) { true } + + rows.forEachIndexed { ordinal, row -> + assertEquals(ordinal, resolveMobileSubtitleOrdinal(mobileSubtitleIdentity(row), rows)) + } + } + + @Test + fun preferencesPickASubtitleWhileTheFileDefaultFlagIsIgnored() { + val rows = localEmbeddedSubtitleRows(mediaTracks) + offlineSidecarSubtitleRows(files) { true } + val audio = listOf( + AudioTrack(index = 0, codec = "aac", channels = 2, language = "jpn"), + AudioTrack(index = 1, codec = "aac", channels = 2, language = "eng"), + ) + + // Japanese audio with an English subtitle preference: the first + // full English text track. + assertEquals( + MobileSubtitleAutoSelection.Select(0), + resolveMobileAutoSubtitleSelection(audio, 0, rows, "eng", null, showForcedSubtitles = true), + ) + // English audio: only a forced English track may turn on. + val forced = resolveMobileAutoSubtitleSelection(audio, 1, rows, "eng", null, showForcedSubtitles = true) + val forcedRow = rows[(forced as MobileSubtitleAutoSelection.Select).ordinal] + assertEquals(true, forcedRow.forced) + // No preference: nothing turns on, whatever the file marks default. + assertEquals( + MobileSubtitleAutoSelection.NoChange, + resolveMobileAutoSubtitleSelection(audio, 0, rows, null, null, showForcedSubtitles = true), + ) + } + + private fun textTrack( + index: Int, + trackId: String, + label: String?, + language: String, + codec: String, + forced: Boolean = false, + ) = MountedSubtitleTrack(index, trackId, label, language, codec, forced, hearingImpaired = false) +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt new file mode 100644 index 000000000..7bd4ccf5d --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/settings/SettingsViewModelOfflinePreferencesTest.kt @@ -0,0 +1,190 @@ +package org.siloserver.silo.android.ui.screens.settings + +import android.app.Application +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.siloserver.silo.common.player.AudiobookSettingsStore +import org.siloserver.silo.common.settings.CardPresentationUiState +import org.siloserver.silo.domain.settings.ProfileSettingsController +import org.siloserver.silo.model.profile.ActiveProfileStore +import org.siloserver.silo.model.profile.Profile +import org.siloserver.silo.model.settings.EffectiveSettingValue +import org.siloserver.silo.model.settings.EffectiveSettingValuesResponse +import org.siloserver.silo.model.settings.SeekIntervalState +import org.siloserver.silo.model.settings.SettingKeys +import org.siloserver.silo.model.settings.SettingScopeIdentity +import org.siloserver.silo.model.settings.SettingsContractCapabilities +import org.siloserver.silo.model.settings.StoredSettingValue +import org.siloserver.silo.network.ApiResult +import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.network.TokenManagerImpl +import org.siloserver.silo.network.api.AuthApi +import org.siloserver.silo.network.api.ProfileApi +import org.siloserver.silo.network.api.SettingsApi +import org.siloserver.silo.network.apiv2.ApiV2Gate +import org.siloserver.silo.network.apiv2.NotificationsV2Api +import org.siloserver.silo.network.apiv2.SettingsV2Api +import org.siloserver.silo.repository.AuthRepository +import org.siloserver.silo.repository.NotificationsRepository +import org.siloserver.silo.repository.ProfileRepository +import org.siloserver.silo.repository.SettingsRepository +import java.lang.reflect.Proxy +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [28]) +class SettingsViewModelOfflinePreferencesTest { + @Test + fun unrelatedResolvedOverrideDoesNotDiscardAConfirmedSubtitleMode() = scenario { vm, api, profiles -> + val modeReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + val metadataReply = api.queueReply() + vm.setMetadataLanguage("fr") + runCurrent() + + // The device overrides both keys. The unrelated response replaces the + // optimistic mode before its own successful response reaches the screen. + metadataReply.complete(snapshot(mode = "off", metadataLanguage = "en")) + runCurrent() + assertEquals(SubtitleMode.OFF, vm.uiState.value.subtitleMode) + modeReply.complete(snapshot(mode = "off")) + runCurrent() + + assertEquals("off", profiles.activeProfile.value?.subtitleMode) + } + + @Test + fun anOlderResponseCannotWinWhenANewerEditReturnsToTheSameValue() = scenario { vm, api, profiles -> + val oldReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + val middleReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + val newestReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + + newestReply.complete(snapshot(mode = "off")) + runCurrent() + middleReply.complete(snapshot(mode = "always")) + runCurrent() + // This older write was narrowed by the effective settings response. + oldReply.complete(snapshot(mode = "auto")) + runCurrent() + + assertEquals(SubtitleMode.OFF, vm.uiState.value.subtitleMode) + assertEquals("off", profiles.activeProfile.value?.subtitleMode) + } + + @Test + fun aFailedNewerEditDoesNotDiscardAnEarlierConfirmedWrite() = scenario { vm, api, profiles -> + val earlierReply = api.queueReply() + vm.setSubtitleMode(SubtitleMode.ALWAYS) + runCurrent() + api.failNextWrite = true + vm.setSubtitleMode(SubtitleMode.OFF) + runCurrent() + assertEquals(SubtitleMode.ALWAYS, vm.uiState.value.subtitleMode) + + earlierReply.complete(snapshot(mode = "always")) + runCurrent() + + assertEquals("always", profiles.activeProfile.value?.subtitleMode) + } + + private fun scenario( + block: suspend TestScope.(SettingsViewModel, PendingSettingsApi, ActiveProfileStore) -> Unit, + ) = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val client = HttpClient(MockEngine { respond("", HttpStatusCode.ServiceUnavailable) }) + val tokens = TokenManagerImpl() + val api = PendingSettingsApi(client, tokens) + val profiles = ActiveProfileStore(object : ProfileRepository(ProfileApi(client, ApiV2Gate.Unrestricted), tokens) { + override suspend fun getActiveProfileId() = "p1" + override suspend fun listProfiles() = ApiResult.Success(listOf(Profile(id = "p1", name = "Test", subtitleMode = "auto"))) + }) + profiles.refresh() + val vm = SettingsViewModel( + authRepository = AuthRepository(AuthApi(client, ApiV2Gate.Unrestricted), tokens), + playerSettingsStore = idleStore(), + libraryPlaybackPrefsStore = idleStore(), + overlayPrefsStore = idleStore(), + activeProfileStore = profiles, + notificationsRepository = NotificationsRepository(NotificationsV2Api(client, tokens, ApiV2Gate.Unrestricted)), + profileSettings = ProfileSettingsController(SettingsRepository(api)), + cardPresentationStore = idleStore(mapOf("getState" to MutableStateFlow(CardPresentationUiState()))), + seekIntervalStore = idleStore(mapOf( + "getState" to MutableStateFlow(SeekIntervalState()), + "getLastError" to MutableStateFlow(null), + )), + audiobookSettingsStore = AudiobookSettingsStore(RuntimeEnvironment.getApplication(), { null }), + ) + try { + runCurrent() + block(vm, api, profiles) + } finally { + vm.viewModelScope.cancel() + client.close() + Dispatchers.resetMain() + } + } + + /** Unrelated settings stores do not emit or start network work in these tests. */ + private inline fun idleStore(values: Map = emptyMap()): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + values[method.name] ?: if (Flow::class.java.isAssignableFrom(method.returnType)) emptyFlow() else Unit + } as T + + private class PendingSettingsApi(client: HttpClient, tokens: TokenManagerImpl) : + SettingsApi(SettingsV2Api(client, tokens, ApiV2Gate.Unrestricted)) { + private val replies = ArrayDeque>>() + var failNextWrite = false + fun queueReply() = CompletableDeferred>().also(replies::addLast) + + override suspend fun getContractCapabilities() = ApiResult.Success(SettingsContractCapabilities(manifestRevision = 1)) + override suspend fun getEffectiveValues(keys: List, libraryIds: List, seriesIds: List, authority: AuthScopeSnapshot?) = + if (replies.isEmpty()) snapshot() else replies.removeFirst().await() + override suspend fun putValue(key: String, scope: SettingScopeIdentity, value: JsonElement, profileId: String?, authority: AuthScopeSnapshot?): ApiResult = + if (failNextWrite) { + failNextWrite = false + ApiResult.Error(503, "unavailable", "Unavailable") + } else { + ApiResult.Success(StoredSettingValue(key = key, scope = "profile")) + } + } + + companion object { + private fun snapshot(mode: String = "auto", metadataLanguage: String = "") = ApiResult.Success( + EffectiveSettingValuesResponse(settings = listOf( + EffectiveSettingValue(key = SettingKeys.PLAYBACK_SUBTITLE_MODE, value = JsonPrimitive(mode)), + EffectiveSettingValue(key = SettingKeys.CATALOG_METADATA_LANGUAGE, value = JsonPrimitive(metadataLanguage)), + )), + ) + } +} diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt index 2bcd349d8..31681b1e2 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/DownloadSidecar.kt @@ -68,6 +68,10 @@ data class DownloadSidecar( * used to send `If-Range` when resuming an interrupted transfer so a changed * source file restarts cleanly instead of corrupting. Null/absent = none. */ val resumeValidator: String? = null, + /** Audio tracks of the downloaded file and the subtitle sidecars saved beside + * it, captured from the offline manifest when the download completed. Null + * for downloads completed before this existed (legacy offline playback). */ + val offlineTracks: OfflineTrackInfo? = null, /** Wall-clock millis when the sidecar was last written. Diagnostic only; * helps debug stale-file scenarios via `ls -la`. */ val updatedAtMs: Long, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt new file mode 100644 index 000000000..46e9faae3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/download/OfflineTracks.kt @@ -0,0 +1,134 @@ +package org.siloserver.silo.model.download + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.siloserver.silo.model.catalog.AudioTrack + +/** + * The part of `GET /api/v2/downloads/{id}/manifest` the offline player needs: + * the audio tracks of the DELIVERED file and the subtitle sidecars to fetch. + * + * `audio_tracks[].index` is the track's position inside the downloaded file, + * not a source-file ordinal: a prepared (remux/transcode) MP4 carries every + * source audio track in source order, a legacy prepared file carries one, and + * an original download is the source file itself. + */ +@Serializable +data class OfflineManifestTracks( + @SerialName("delivery_format") val deliveryFormat: String? = null, + @SerialName("selected_audio_track_index") val selectedAudioTrackIndex: Int? = null, + @SerialName("audio_tracks") val audioTracks: List = emptyList(), + val subtitles: List = emptyList(), +) + +@Serializable +data class OfflineManifestSubtitle( + val language: String? = null, + val title: String? = null, + val format: String? = null, + val forced: Boolean = false, + @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, + @SerialName("fetch_url") val fetchUrl: String = "", +) + +/** + * Track data persisted with a [DownloadSidecar] so a download can offer its + * audio and subtitle choices with no network. Absent on downloads completed + * before this existed; those keep the legacy offline behaviour. + */ +@Serializable +data class OfflineTrackInfo( + /** + * The downloaded file's audio tracks in file order. The server numbers + * them 0..n-1 in list order, so [AudioTrack.index] and the list position + * are both the track's position among the file's Media3 audio groups. + */ + val audioTracks: List = emptyList(), + /** Position (into [audioTracks]) to select by default. */ + val selectedAudioTrackIndex: Int? = null, + val subtitles: List = emptyList(), + /** + * True for server-prepared files, whose re-encoded tracks are selected by + * position. An original download is the source file with its source + * codecs; Media3 does not report every container's audio groups in file + * order (Matroska can differ), so originals select by catalog identity. + */ + val audioByPosition: Boolean = false, +) { + /** The default audio position: the manifest's pick, else the default flag, else the first. */ + fun defaultAudioPosition(): Int { + if (audioTracks.isEmpty()) return 0 + selectedAudioTrackIndex?.takeIf { it in audioTracks.indices }?.let { return it } + return audioTracks.indexOfFirst { it.isDefault }.takeIf { it >= 0 } ?: 0 + } +} + +/** A subtitle sidecar fetched once at download time and kept beside the download. */ +@Serializable +data class OfflineSubtitleFile( + /** Absolute path of the local file. Its extension matches [format]. */ + val path: String, + /** Canonical format: `srt`, `vtt`, `ass`, `ssa`, `pgs`, or `ttml`. */ + val format: String, + val language: String? = null, + val title: String? = null, + val forced: Boolean = false, + val hearingImpaired: Boolean = false, +) + +private val offlineManifestJson = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true +} + +/** Decodes the manifest subset; null for a body that is not a manifest object. */ +fun decodeOfflineManifestTracks(body: String): OfflineManifestTracks? = + runCatching { offlineManifestJson.decodeFromString(OfflineManifestTracks.serializer(), body) }.getOrNull() + +/** Builds the persisted track data from a manifest and the sidecars saved from it. */ +fun OfflineManifestTracks.toOfflineTrackInfo(savedSubtitles: List): OfflineTrackInfo = + OfflineTrackInfo( + audioTracks = audioTracks, + selectedAudioTrackIndex = selectedAudioTrackIndex?.takeIf { it in audioTracks.indices }, + subtitles = savedSubtitles, + audioByPosition = !deliveryFormat.equals(DELIVERY_FORMAT_ORIGINAL, ignoreCase = true), + ) + +private const val DELIVERY_FORMAT_ORIGINAL = "original" + +/** + * Canonical local format for a manifest subtitle `format`, or null when the + * player cannot mount it as a sidecar (so it is not downloaded at all). + */ +fun offlineSubtitleFormat(format: String?): String? = + when (format?.trim()?.lowercase()?.removePrefix(".")) { + "srt", "subrip" -> "srt" + "vtt", "webvtt" -> "vtt" + "ass" -> "ass" + "ssa" -> "ssa" + "sup", "pgs", "hdmv_pgs_subtitle" -> "pgs" + "ttml", "dfxp" -> "ttml" + else -> null + } + +/** File extension for a canonical [offlineSubtitleFormat] value. */ +fun offlineSubtitleExtension(format: String): String = when (format) { + "pgs" -> "sup" + else -> format +} + +/** + * Only same-server managed-download subtitle proxy paths are fetched. The URL + * is authenticated with the download owner's credentials, so an absolute or + * foreign reference in a manifest must never be followed. + */ +fun isOfflineSubtitleFetchUrl(url: String): Boolean { + val trimmed = url.trim() + if (!trimmed.startsWith(OFFLINE_DOWNLOADS_PREFIX)) return false + if (trimmed.contains("://") || trimmed.contains("..") || trimmed.contains('\\')) return false + return trimmed.substring(OFFLINE_DOWNLOADS_PREFIX.length).contains("/subtitles/") +} + +private const val OFFLINE_DOWNLOADS_PREFIX = "/api/v2/downloads/" diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ActiveProfileStore.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ActiveProfileStore.kt index 4f933f9fa..1537e9be9 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ActiveProfileStore.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ActiveProfileStore.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.model.profile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import org.siloserver.silo.network.ApiResult import org.siloserver.silo.repository.ProfileRepository @@ -71,6 +72,11 @@ class ActiveProfileStore( } } + /** Apply a local edit to the cached profile, if one is cached. */ + fun update(transform: (Profile) -> Profile) { + _activeProfile.update { it?.let(transform) } + } + /** Clear on sign-out or a server switch, so no profile leaks across accounts. */ fun reset() { loadedForProfileId = null diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt new file mode 100644 index 000000000..9d4515eb6 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/download/OfflineTracksTest.kt @@ -0,0 +1,146 @@ +package org.siloserver.silo.model.download + +import org.siloserver.silo.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OfflineTracksTest { + + /** A multi-track prepared download, shaped like the server's manifest. */ + private val preparedManifest = """ + { + "download_id": "dl_1", + "media_file_id": "4567", + "delivery_format": "transcode", + "container": "mp4", + "selected_audio_track_index": 1, + "audio_tracks": [ + {"index": 0, "language": "eng", "title": "Main", "codec": "aac", "layout": "stereo", "channels": 2, "default": true}, + {"index": 1, "language": "jpn", "codec": "aac", "channels": 2, "default": false}, + {"index": 2, "language": "eng", "title": "Commentary", "codec": "aac", "channels": 2, "default": false} + ], + "subtitles": [ + {"language": "eng", "format": "ass", "forced": false, "hearing_impaired": false, + "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:2"}, + {"language": "eng", "format": "sup", "forced": true, "hearing_impaired": false, + "fetch_url": "/api/v2/downloads/dl_1/subtitles/embedded:3"} + ], + "manifest_version": 3 + } + """.trimIndent() + + @Test + fun decodesTheServerManifestIgnoringFieldsItDoesNotUse() { + val manifest = assertNotNull(decodeOfflineManifestTracks(preparedManifest)) + + assertEquals("transcode", manifest.deliveryFormat) + assertEquals(1, manifest.selectedAudioTrackIndex) + assertEquals(3, manifest.audioTracks.size) + assertEquals(listOf("ass", "sup"), manifest.subtitles.map { it.format }) + assertTrue(manifest.subtitles[1].forced) + } + + @Test + fun audioTracksKeepFilePositionsAndTheManifestSelection() { + val info = assertNotNull(decodeOfflineManifestTracks(preparedManifest)).toOfflineTrackInfo(emptyList()) + assertTrue(info.audioByPosition) + + assertEquals(listOf(0, 1, 2), info.audioTracks.map { it.index }) + assertEquals(listOf("Main", null, "Commentary"), info.audioTracks.map { it.title }) + assertEquals(1, info.defaultAudioPosition()) + assertEquals("jpn", info.audioTracks[1].language) + } + + @Test + fun originalDownloadsKeepAudioRowsWithoutPositionalSelection() { + val manifest = assertNotNull( + decodeOfflineManifestTracks(preparedManifest.replace("\"transcode\"", "\"original\"")), + ) + val sidecar = OfflineSubtitleFile(path = "subtitles/0.srt", format = "srt", language = "eng") + + val info = manifest.toOfflineTrackInfo(listOf(sidecar)) + + // Media3 may not report a source container's audio groups in file order, + // so an original file keeps its menu rows but is not selected by position. + assertFalse(info.audioByPosition) + assertEquals(3, info.audioTracks.size) + assertEquals(1, info.defaultAudioPosition()) + assertEquals(listOf(sidecar), info.subtitles) + } + + @Test + fun anOutOfRangeManifestSelectionIsDropped() { + val manifest = OfflineManifestTracks( + selectedAudioTrackIndex = 2, + audioTracks = listOf(AudioTrack(index = 0, language = "eng"), AudioTrack(index = 1, language = "fre")), + ) + + assertNull(manifest.toOfflineTrackInfo(emptyList()).selectedAudioTrackIndex) + } + + @Test + fun defaultAudioFallsBackToTheDefaultFlagThenTheFirstTrack() { + val flagged = OfflineTrackInfo( + audioTracks = listOf( + AudioTrack(index = 0, language = "eng"), + AudioTrack(index = 1, language = "fre", isDefault = true), + ), + selectedAudioTrackIndex = 7, + ) + assertEquals(1, flagged.defaultAudioPosition()) + assertEquals(0, flagged.copy(audioTracks = flagged.audioTracks.map { it.copy(isDefault = false) }).defaultAudioPosition()) + assertEquals(0, OfflineTrackInfo().defaultAudioPosition()) + } + + @Test + fun legacyPreparedManifestDescribesItsSingleTrack() { + val manifest = assertNotNull( + decodeOfflineManifestTracks( + """{"selected_audio_track_index":0,"audio_tracks":[{"index":0,"language":"en","codec":"aac","default":true}],"subtitles":[]}""", + ), + ) + val info = manifest.toOfflineTrackInfo(emptyList()) + + assertEquals(1, info.audioTracks.size) + assertEquals(0, info.defaultAudioPosition()) + assertTrue(info.subtitles.isEmpty()) + } + + @Test + fun manifestWithoutTrackFieldsDecodesEmpty() { + val manifest = assertNotNull(decodeOfflineManifestTracks("""{"download_id":"dl_1","subtitles":null}""")) + + assertTrue(manifest.audioTracks.isEmpty()) + assertTrue(manifest.subtitles.isEmpty()) + assertNull(decodeOfflineManifestTracks("not json")) + } + + @Test + fun subtitleFormatsMapToMountableLocalFormats() { + assertEquals("srt", offlineSubtitleFormat("srt")) + assertEquals("srt", offlineSubtitleFormat("SubRip")) + assertEquals("vtt", offlineSubtitleFormat("webvtt")) + assertEquals("ass", offlineSubtitleFormat("ass")) + assertEquals("ssa", offlineSubtitleFormat("ssa")) + assertEquals("pgs", offlineSubtitleFormat("sup")) + assertEquals("ttml", offlineSubtitleFormat("ttml")) + assertNull(offlineSubtitleFormat("sub")) + assertNull(offlineSubtitleFormat(null)) + assertEquals("sup", offlineSubtitleExtension("pgs")) + assertEquals("ass", offlineSubtitleExtension("ass")) + } + + @Test + fun onlyManagedDownloadSubtitleProxyPathsAreFetched() { + assertTrue(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/subtitles/embedded:2")) + assertTrue(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/subtitles/downloaded:17")) + assertFalse(isOfflineSubtitleFetchUrl("https://evil.example/api/v2/downloads/dl_1/subtitles/external:0")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/../../admin/subtitles/x")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v2/downloads/dl_1/artwork/poster")) + assertFalse(isOfflineSubtitleFetchUrl("/api/v1/downloads/dl_1/subtitles/external:0")) + } +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/model/profile/ActiveProfileStoreTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/model/profile/ActiveProfileStoreTest.kt index 44d46bcd3..bda205393 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/model/profile/ActiveProfileStoreTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/model/profile/ActiveProfileStoreTest.kt @@ -68,6 +68,20 @@ class ActiveProfileStoreTest { assertEquals(2, repo.listCalls) } + @Test + fun aLocalEditUpdatesOnlyACachedProfile() = runTest { + val repo = FakeProfileRepository() + val store = ActiveProfileStore(repo) + + store.update { it.copy(subtitleLanguage = "fr") } + assertEquals(null, store.activeProfile.value) + + store.refresh() + store.update { it.copy(subtitleLanguage = "fr") } + assertEquals("fr", store.activeProfile.value?.subtitleLanguage) + assertEquals(1, repo.listCalls) + } + @Test fun aFailedRefreshKeepsTheCachedProfile() = runTest { val repo = FakeProfileRepository()