package link.roadio.riff.userstate import link.roadio.riff.config.UserHistoryPolicy import link.roadio.riff.userstate.model.FileHeaderModel import link.roadio.riff.userstate.model.InstallationStateModel import link.roadio.riff.userstate.model.ListeningHistoryModel import link.roadio.riff.userstate.model.MediaKeyModel import link.roadio.riff.userstate.model.ObjectKeyModel import link.roadio.riff.userstate.model.ProfileStateModel import link.roadio.riff.userstate.model.QueueRevisionModel import link.roadio.riff.userstate.model.QueueStateModel import link.roadio.riff.userstate.model.UserStateCodec object UserStateValidation { const val CAPACITY = UserHistoryPolicy.CAPACITY const val ALIGNMENT = UserHistoryPolicy.ALIGNMENT const val HISTORY_BYTES = UserHistoryPolicy.MAX_FILE_BYTES const val HISTORY_DECAY = UserHistoryPolicy.DEFAULT_DECAY const val HISTORY_WEIGHT = UserHistoryPolicy.DEFAULT_WEIGHT fun header( header: FileHeaderModel, slot: Int, ) { require(header.formatVersion == 1 && header.profileSlot == slot) require(header.generation >= 0 && header.receipt.sequence >= 0) } fun profile( profile: ProfileStateModel, ids: ContentIds? = null, ) { require(profile.header.profileSlot in 1..3) header(profile.header, profile.header.profileSlot) val settings = profile.settings require(settings.theme in 0..2 && settings.contentMode in 0..1) require(settings.audiobookSpeedTenths in 7..15) require(settings.aiQueueSize in setOf(10, 20, 50)) require(settings.aiPteBands in 0..7 && Integer.bitCount(settings.aiPteBands) <= 2) require(settings.aiArousal.isFinite() && settings.aiArousal in -1.0..1.0) require(settings.aiValence.isFinite() && settings.aiValence in -1.0..1.0) require(profile.dynamicQueue.id == null) queue(profile.dynamicQueue, saved = false, ids = ids) require( profile.savedQueues .map { checkNotNull(it.id) } .distinct() .size == profile.savedQueues.size, ) profile.savedQueues.forEach { q -> queue(q, saved = true, ids = ids) if (ids != null) { val expected = if (q.kind == 1 ) { ids.savedBookQueue(profile.header.profileSlot, checkNotNull(q.titleId)) } else { ids.savedMusicQueue(profile.header.profileSlot, checkNotNull(q.creationId)) } require(q.id == expected) { "Invalid saved queue identity" } q.bookmarks.forEach { mark -> require( mark.id == ids.bookmark(profile.header.profileSlot, checkNotNull(q.id), checkNotNull(mark.occurrenceId), mark.positionUs), ) { "Invalid bookmark identity" } } } } val titles = profile.savedQueues.filter { it.kind == 1 }.map { checkNotNull(it.titleId) } require(titles.distinct().size == titles.size) profile.lastQueue?.id?.let { selected -> require(profile.savedQueues.any { it.id == selected }) } profile.reactions.forEach { reaction -> key(reaction.target) require(reaction.preference in 0..2) } profile.classifications.forEach { override -> key(override.target) require(override.kind in 0..1) } require( profile.reactions .map { it.target } .distinct() .size == profile.reactions.size, ) require( profile.classifications .map { it.target } .distinct() .size == profile.classifications.size, ) } fun key(key: ObjectKeyModel) { require(key.kind in 0..4) if (key.kind == 0) { require(key.objectId == null) media(checkNotNull(key.mediaId)) } else { require(key.objectId != null && key.mediaId == null) } } fun media(media: MediaKeyModel) { require(media.loPresent || media.lo == 0L) } private fun revision( value: QueueRevisionModel, ids: ContentIds?, ) { require(value.id != null) if (ids != null) { require( value.id == ids.queueRevision(value.items), ) { "Invalid execution/definition identity" } } require( value.items .map { checkNotNull(it.occurrenceId) } .distinct() .size == value.items.size, ) value.items.forEach { media(it.mediaId) require(it.knownDurationUs == -1L || it.knownDurationUs > 0) } } fun queue( queue: QueueStateModel, saved: Boolean, ids: ContentIds? = null, ) { require(queue.kind in 0..1) require(!saved || queue.id != null) require(saved || queue.bookmarks.isEmpty()) revision(queue.definition, ids) revision(queue.execution, ids) if (saved) require(queue.definition.items.isNotEmpty()) if (queue.kind == 0) { require(queue.bookmarks.isEmpty()) if (saved) require(queue.creationId != null) } else { require(!queue.checkpoint.shuffleEnabled) if (saved) require(queue.titleId != null && queue.bookmarks.isNotEmpty()) } val cp = queue.checkpoint require(cp.revisionId == queue.execution.id && cp.positionUs >= 0) val ids = queue.execution.items.map { it.occurrenceId } if (ids.isEmpty()) { require(cp.occurrenceId == null && cp.positionUs == 0L) } else { require(cp.occurrenceId != null && cp.occurrenceId in ids) } if (cp.shuffleEnabled) require(cp.shuffleOrder.size == ids.size && cp.shuffleOrder.toSet() == ids.toSet()) require(!cp.historyRecorded || (cp.attemptId != null && queue.kind == 0)) require(cp.shuffleOrder.isEmpty() || (cp.shuffleOrder.size == ids.size && cp.shuffleOrder.toSet() == ids.toSet())) require(!saved || !queue.name.isNullOrBlank()) require( queue.bookmarks .map { checkNotNull(it.id) } .distinct() .size == queue.bookmarks.size, ) queue.bookmarks.forEach { require( it.positionUs >= 0 && it.occurrenceId != null && queue.definition.items.any { item -> item.occurrenceId == it.occurrenceId }, ) } } fun history(history: ListeningHistoryModel) { val slot = history.header.profileSlot require(slot in 1..3) header(history.header, slot) require(history.entries.size <= CAPACITY) var previous = 0L history.entries.forEach { entry -> require(entry.generation in previous..history.header.generation) previous = entry.generation val flags = entry.flagsData ushr 56 require((entry.flagsData and 0x00ffffffffffffffL) == 0L) require(flags and 0xf8L == 0L && (flags ushr 1).toInt() == slot) require(flags and 1L != 0L || entry.mediaLo == 0L) } } fun qualifications(profile: ProfileStateModel): Map { val entries = (listOf(profile.dynamicQueue) + profile.savedQueues).mapNotNull { q -> val cp = q.checkpoint if (!cp.historyRecorded) { null } else { require(q.kind == 0) checkNotNull(cp.attemptId) to checkNotNull(q.execution.items.firstOrNull { it.occurrenceId == cp.occurrenceId }).mediaId } } require(entries.map { it.first }.distinct().size == entries.size) { "An attempt belongs to one queue" } return entries.toMap() } fun transition( prior: Map?, next: Map, replaced: Set, ) { for (slot in 1..3) { val pkey = UserSnapshotStore.Key(1, slot) val hkey = UserSnapshotStore.Key(2, slot) val profile = UserStateCodec.decodeProfileState(next.getValue(pkey)) val history = UserStateCodec.decodeListeningHistory(next.getValue(hkey)) if (prior == null) { require(history.entries.isEmpty() && qualifications(profile).isEmpty()) { "New stores begin without playback history" } continue } val oldProfile = UserStateCodec.decodeProfileState(prior.getValue(pkey)) val before = qualifications(oldProfile) val after = qualifications(profile) after.forEach { (attempt, media) -> require(before[attempt] == null || before[attempt] == media) { "Attempt media changed" } } val newlyQualified = after.filterKeys { it !in before }.values if (hkey !in replaced) { require(newlyQualified.isEmpty()) { "Qualification requires atomic history publication" } continue } require(pkey in replaced) require( profile.header.receipt.writerId == history.header.receipt.writerId && profile.header.receipt.sequence >= history.header.receipt.sequence, ) val additions = history.entries.filter { it.generation == history.header.generation } require(additions.isNotEmpty()) { "History cannot advance without starts" } val oldHistory = UserStateCodec.decodeListeningHistory(prior.getValue(hkey)) val retained = history.entries.filter { it.generation != history.header.generation } require(retained == oldHistory.entries.takeLast(retained.size)) { "Retained history was rewritten" } if (additions.size < CAPACITY) { val recorded = additions .groupingBy { MediaKeyModel( it.mediaHi, it.mediaLo, (it.flagsData ushr 56) and 1L != 0L, ) }.eachCount() newlyQualified.groupingBy { it }.eachCount().forEach { (media, count) -> require((recorded[media] ?: 0) >= count) { "Qualification has no history append" } } } } } fun installation(state: InstallationStateModel) { header(state.header, 0) require(state.activeProfileSlot in 1..3 && state.playbackProfileSlot in 0..3) require(state.diagonalMode in 0..2 && state.dimBacklight in 0..3 && state.replayGainMode in 0..2) require(state.replayGainTarget in setOf(0, 16, 18, 23)) when (state.diagonalMode) { 0 -> require(state.calibrationPpiX == null && state.calibrationPpiY == null) 1 -> require(state.calibrationPpiX != null && state.calibrationPpiY == null) 2 -> require(state.calibrationPpiX != null && state.calibrationPpiY != null) } state.calibrationPpiX?.let { require(it.isFinite() && it > 0) } state.calibrationPpiY?.let { require(it.isFinite() && it > 0) } } }