182 lines
6.3 KiB
Kotlin
182 lines
6.3 KiB
Kotlin
@file:OptIn(kotlin.time.ExperimentalTime::class)
|
|
|
|
package com.rukira.wowbackup.backup
|
|
|
|
import com.rukira.wowbackup.config.ConfigManager
|
|
import io.github.kevincianfarini.cardiologist.schedulePulse
|
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.flow.update
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.datetime.DateTimeUnit
|
|
import kotlinx.datetime.LocalDateTime
|
|
import kotlinx.datetime.TimeZone
|
|
import kotlinx.datetime.plus
|
|
import kotlinx.datetime.toLocalDateTime
|
|
import java.io.File
|
|
import kotlin.time.Clock
|
|
|
|
private val logger = KotlinLogging.logger {}
|
|
|
|
data class SchedulerState(
|
|
val lastBackupTime: LocalDateTime? = null,
|
|
val lastBackupResult: BackupResult? = null,
|
|
val nextBackupTime: LocalDateTime? = null,
|
|
val isRunning: Boolean = false,
|
|
val currentProgress: BackupProgress? = null,
|
|
val isWoWRunning: Boolean = false,
|
|
)
|
|
|
|
object BackupScheduler {
|
|
|
|
private val scope = CoroutineScope(Dispatchers.Default)
|
|
private var schedulerJob: Job? = null
|
|
private var pulseJob: Job? = null
|
|
private var backupJob: Job? = null
|
|
|
|
private val _state = MutableStateFlow(SchedulerState())
|
|
val state: StateFlow<SchedulerState> = _state.asStateFlow()
|
|
|
|
fun start() {
|
|
if (schedulerJob?.isActive == true) return
|
|
logger.info { "Backup scheduler started" }
|
|
|
|
schedulerJob = scope.launch {
|
|
// Collect progress from BackupEngine
|
|
launch {
|
|
BackupEngine.progress.collect { progress ->
|
|
_state.update { it.copy(currentProgress = progress) }
|
|
}
|
|
}
|
|
|
|
// React to config changes and reschedule the pulse
|
|
ConfigManager.config.collect { config ->
|
|
// Only cancel the pulse (the waiting), not a running backup
|
|
pulseJob?.cancel()
|
|
|
|
if (!config.isConfigured) {
|
|
_state.update { it.copy(nextBackupTime = null) }
|
|
return@collect
|
|
}
|
|
|
|
val tz = TimeZone.currentSystemDefault()
|
|
val targetTime = config.backupTimeOfDay
|
|
logger.info { "Scheduling daily backup at $targetTime ($tz)" }
|
|
|
|
updateNextBackupTime(tz)
|
|
|
|
pulseJob = launch {
|
|
Clock.System.schedulePulse(
|
|
atHour = targetTime.hour,
|
|
atMinute = targetTime.minute,
|
|
atSecond = 0,
|
|
timeZone = tz,
|
|
).beat { _ ->
|
|
// Read fresh config at execution time, not the stale closure value
|
|
val currentConfig = ConfigManager.config.value
|
|
val wowRunning = WoWProcessDetector.isWoWRunning()
|
|
_state.update { it.copy(isWoWRunning = wowRunning) }
|
|
|
|
if (wowRunning && !currentConfig.forceBackupWhileRunning) {
|
|
logger.info { "Backup time reached but WoW is running (force disabled), skipping" }
|
|
BackupNotifier.notifyBackupSkipped("WoW is running")
|
|
} else {
|
|
// Launch backup in a separate job so it survives pulse cancellation
|
|
// (e.g. if user changes config while backup is running)
|
|
backupJob = scope.launch { executeBackup() }
|
|
backupJob?.join()
|
|
}
|
|
|
|
updateNextBackupTime(tz)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun stop() {
|
|
schedulerJob?.cancel()
|
|
pulseJob?.cancel()
|
|
backupJob?.cancel()
|
|
schedulerJob = null
|
|
pulseJob = null
|
|
backupJob = null
|
|
logger.info { "Backup scheduler stopped" }
|
|
}
|
|
|
|
fun triggerBackupNow() {
|
|
if (_state.value.isRunning) {
|
|
logger.info { "Backup already running, ignoring manual trigger" }
|
|
return
|
|
}
|
|
logger.info { "Manual backup triggered" }
|
|
backupJob = scope.launch {
|
|
executeBackup()
|
|
updateNextBackupTime(TimeZone.currentSystemDefault())
|
|
}
|
|
}
|
|
|
|
private suspend fun executeBackup() {
|
|
val config = ConfigManager.config.value
|
|
if (!config.isConfigured) return
|
|
|
|
_state.update { it.copy(isRunning = true) }
|
|
BackupNotifier.notifyBackupStarted()
|
|
|
|
val startTime = System.currentTimeMillis()
|
|
val result = BackupEngine.runBackup(config)
|
|
val duration = System.currentTimeMillis() - startTime
|
|
|
|
val tz = TimeZone.currentSystemDefault()
|
|
val now = now(tz)
|
|
|
|
_state.update {
|
|
it.copy(
|
|
isRunning = false,
|
|
currentProgress = null,
|
|
lastBackupTime = now,
|
|
lastBackupResult = when (result) {
|
|
is BackupResult.Success -> result.copy(durationMs = duration)
|
|
is BackupResult.Failure -> result
|
|
},
|
|
)
|
|
}
|
|
|
|
when (result) {
|
|
is BackupResult.Success -> {
|
|
val sizeMb = "%.1f MB".format(result.sizeBytes / (1024.0 * 1024.0))
|
|
BackupNotifier.notifyBackupComplete(result.fileCount, sizeMb)
|
|
|
|
val backupDir = File(config.backupPath!!)
|
|
BackupHistory.pruneOldBackups(backupDir, config.backupHistoryCount)
|
|
}
|
|
is BackupResult.Failure -> {
|
|
BackupNotifier.notifyBackupFailed(result.reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun updateNextBackupTime(tz: TimeZone) {
|
|
val config = ConfigManager.config.value
|
|
val targetTime = config.backupTimeOfDay
|
|
val now = now(tz)
|
|
val today = now.date
|
|
val todayTarget = LocalDateTime(today, targetTime)
|
|
|
|
val next = if (now < todayTarget) {
|
|
todayTarget
|
|
} else {
|
|
val tomorrow = today.plus(1, DateTimeUnit.DAY)
|
|
LocalDateTime(tomorrow, targetTime)
|
|
}
|
|
|
|
_state.update { it.copy(nextBackupTime = next) }
|
|
}
|
|
|
|
private fun now(tz: TimeZone): LocalDateTime = Clock.System.now().toLocalDateTime(tz)
|
|
}
|