diff --git a/.editorconfig b/.editorconfig index c11958d..934fa90 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,3 +17,8 @@ ij_java_use_single_class_imports = true [*.yml] indent_size = 2 + +[{*.kt,*.kts}] +ij_kotlin_name_count_to_use_star_import = 99 +ij_kotlin_name_count_to_use_star_import_for_members = 99 +ij_kotlin_packages_to_use_import_on_demand = unset \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/ui/SettingsScreen.kt index b3adfbd..e1beb17 100644 --- a/composeApp/src/commonMain/kotlin/ui/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/SettingsScreen.kt @@ -16,8 +16,8 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.sharp.Delete import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedCard @@ -37,11 +37,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import io.github.jsixface.common.Settings import kotlinx.coroutines.launch import org.koin.compose.koinInject -import ui.model.Screen import ui.model.ModelState +import ui.model.Screen +import ui.utils.ComboBox import viewmodels.SettingsScreenModel +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes object SettingsScreen : Screen { @@ -73,6 +78,7 @@ object SettingsScreen : Screen { var workspace by remember { mutableStateOf("") } val extensions = remember { mutableStateListOf() } val locations = remember { mutableStateListOf() } + var refreshDuration by remember { mutableStateOf(null) } LaunchedEffect(Unit) { scope.launch { @@ -99,6 +105,7 @@ object SettingsScreen : Screen { } extensions.addAll(extsToAdd) workspace = settings.workspaceLocation + refreshDuration = settings.watchDuration } } } @@ -119,19 +126,25 @@ object SettingsScreen : Screen { } Column { ListEditor("Locations", locations, { locations.remove(it) }, { locations.add(it) }) - Divider() + HorizontalDivider() ListEditor("Extensions", extensions, { extensions.remove(it) }, { extensions.add(it) }) - Divider() + HorizontalDivider() OutlinedTextField(value = workspace, onValueChange = { workspace = it }, modifier = padding, label = { Text("Workspace Location") }) + ComboBox( + title = "Media Scan Duration", + options = listOf(1.minutes, 5.minutes, 15.minutes, 30.minutes, 1.hours), + selected = refreshDuration + ) { refreshDuration = it } + } Row(modifier = Modifier.fillMaxSize()) { Spacer(modifier = Modifier.weight(1f)) ElevatedButton(onClick = { scope.launch { - settingsModel.save(locations.toList(), extensions.toList(), workspace) + settingsModel.save(Settings(locations, workspace, extensions, refreshDuration)) } }, modifier = padding) { Text("Save") diff --git a/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt index 29c89fc..44d9769 100644 --- a/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt @@ -3,17 +3,32 @@ package ui.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -23,13 +38,14 @@ import kotlinx.coroutines.launch import org.koin.compose.koinInject import ui.model.ModelState import ui.model.Screen +import ui.utils.ComboBox import viewmodels.VideoListViewModel object HomeScreen : Screen { - private var filteredAudioCodec by mutableStateOf("") - private var filteredVideoCodec by mutableStateOf("") + private var filteredAudioCodec by mutableStateOf(null) + private var filteredVideoCodec by mutableStateOf(null) private var filteredName by mutableStateOf("") private var selectedVideo by mutableStateOf(null) private var showFileDetails by mutableStateOf(false) @@ -128,8 +144,8 @@ object HomeScreen : Screen { val filteredVideos = list.filter { it.fileName.contains(filteredName, ignoreCase = true) - && it.videos.any { v -> if (filteredVideoCodec != "") v.codec == filteredVideoCodec else true } - && it.audios.any { a -> if (filteredAudioCodec != "") a.codec == filteredAudioCodec else true } + && it.videos.any { v -> filteredVideoCodec?.let { fv -> v.codec == fv } ?: true } + && it.audios.any { a -> filteredAudioCodec?.let { fa -> a.codec == fa } ?: true } } Column { Row(modifier = bottomPad.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -139,18 +155,18 @@ object HomeScreen : Screen { label = { Text("File name") }, leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = "Search") }) val videoOptions = list.asSequence().flatMap { it.videos }.map { it.codec }.toSet().toList().sorted() - FilterOptions("Video Codecs", videoOptions, filteredVideoCodec) { filteredVideoCodec = it } + ComboBox("Video Codecs", videoOptions, filteredVideoCodec) { filteredVideoCodec = it } val audioOptions = list.asSequence().flatMap { it.audios }.map { it.codec }.toSet().toList().sorted() - FilterOptions("Audio Codecs", audioOptions, filteredAudioCodec) { filteredAudioCodec = it } + ComboBox("Audio Codecs", audioOptions, filteredAudioCodec) { filteredAudioCodec = it } IconButton(modifier = sidePad, onClick = onRefresh) { Icon(Icons.Rounded.Refresh, contentDescription = "Refresh") } // Clear filters - if (filteredName.isNotEmpty() || filteredAudioCodec.isNotEmpty() || filteredVideoCodec.isNotEmpty()) { + if (filteredName.isNotEmpty() || filteredAudioCodec != null || filteredVideoCodec != null) { IconButton(onClick = { filteredName = "" - filteredAudioCodec = "" - filteredVideoCodec = "" + filteredAudioCodec = null + filteredVideoCodec = null }) { Icon(Icons.Rounded.Close, contentDescription = "Clear filters") } @@ -185,51 +201,6 @@ object HomeScreen : Screen { } } - @OptIn(ExperimentalMaterial3Api::class) - @Composable - private fun FilterOptions( - title: String, - options: List, - selected: String, - onSelect: (String) -> Unit - ) { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox(expanded, onExpandedChange = { expanded = it }, modifier = sidePad) { - TextField( - modifier = sidePad.menuAnchor(), - value = selected, - onValueChange = { }, - readOnly = true, - label = { Text(title) }, - colors = ExposedDropdownMenuDefaults.textFieldColors(), - trailingIcon = { Icon(Icons.Rounded.ArrowDropDown, contentDescription = "Select") }, - ) - - if (options.isNotEmpty()) { - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text("None") }, - onClick = { - onSelect("") - expanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - options.forEach { codec -> - DropdownMenuItem( - text = { Text(codec) }, - onClick = { - onSelect(codec) - expanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } - } - } - } - @Composable private fun VideoRow(file: VideoFile, onClick: (VideoFile) -> Unit) { Column { diff --git a/composeApp/src/commonMain/kotlin/ui/utils/ComboBox.kt b/composeApp/src/commonMain/kotlin/ui/utils/ComboBox.kt new file mode 100644 index 0000000..b82e046 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/utils/ComboBox.kt @@ -0,0 +1,69 @@ +package ui.utils + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ComboBox( + title: String, + options: List, + selected: T?, + optionName: T.() -> String = { toString() }, + onSelect: (T?) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.padding(8.dp, 0.dp, 0.dp, 0.dp) + ) { + OutlinedTextField( + modifier = Modifier.menuAnchor(), + value = selected?.optionName() ?: "", + onValueChange = { }, + readOnly = true, + label = { Text(title) }, + trailingIcon = { Icon(Icons.Rounded.ArrowDropDown, contentDescription = "Select") }, + ) + + if (options.isNotEmpty()) { + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + onSelect(null) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + options.forEach { option -> + DropdownMenuItem( + text = { Text(option.optionName()) }, + onClick = { + onSelect(option) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/viewmodels/SettingsScreenModel.kt b/composeApp/src/commonMain/kotlin/viewmodels/SettingsScreenModel.kt index e715867..0b12c83 100644 --- a/composeApp/src/commonMain/kotlin/viewmodels/SettingsScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/viewmodels/SettingsScreenModel.kt @@ -12,10 +12,10 @@ import io.ktor.http.contentType import io.ktor.http.isSuccess import kotlinx.coroutines.flow.flow import ui.model.ModelState -import util.log import ui.model.ModelState.Error import ui.model.ModelState.Init import ui.model.ModelState.Success +import util.log class SettingsScreenModel(private val client: HttpClient) { @@ -33,15 +33,8 @@ class SettingsScreenModel(private val client: HttpClient) { } } - suspend fun save(locations: List, extension: List, workLocation: String) { - val settings = Settings( - libraryLocations = locations, - workspaceLocation = workLocation, - videoExtensions = extension - ) - client.post(Api.Settings) { - contentType(ContentType.Application.Cbor) - setBody(settings) - } + suspend fun save(settings: Settings) = client.post(Api.Settings) { + contentType(ContentType.Application.Cbor) + setBody(settings) } } \ No newline at end of file diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/Application.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/Application.kt index 2bf8358..2b28f3c 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/Application.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/Application.kt @@ -3,7 +3,6 @@ package io.github.jsixface.codexvert import io.github.jsixface.codexvert.plugins.configureHTTP import io.github.jsixface.codexvert.plugins.configureKoin import io.github.jsixface.codexvert.plugins.configureRouting -import io.github.jsixface.codexvert.plugins.configureWatchers import io.ktor.server.application.Application import io.ktor.server.netty.EngineMain import org.slf4j.Logger @@ -15,7 +14,6 @@ fun Application.module() { configureKoin() configureRouting() configureHTTP() - configureWatchers() } diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/api/ConversionApi.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/api/ConversionApi.kt index 102b073..74f5c81 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/api/ConversionApi.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/api/ConversionApi.kt @@ -5,8 +5,15 @@ import io.github.jsixface.common.Conversion import io.github.jsixface.common.MediaTrack import io.github.jsixface.common.VideoFile import io.ktor.utils.io.CancellationException -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.datetime.Clock.System import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -15,17 +22,17 @@ import java.io.File import java.io.InputStream import java.io.UncheckedIOException import java.nio.file.Files -import java.util.* +import java.util.UUID import kotlin.io.path.Path import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -class ConversionApi(settingsApi: SettingsApi) { +class ConversionApi { private val logger = logger() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) val jobs = mutableListOf() - private val workspace = File(settingsApi.getSettings().workspaceLocation) + private val workspace = File(SavedData.load().settings.workspaceLocation) init { if (workspace.isDirectory.not()) { diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/api/SettingsApi.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/api/SettingsApi.kt index f0ec561..098d368 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/api/SettingsApi.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/api/SettingsApi.kt @@ -1,10 +1,11 @@ package io.github.jsixface.codexvert.api -import io.github.jsixface.common.Settings import io.github.jsixface.codexvert.logger +import io.github.jsixface.codexvert.plugins.Watchers +import io.github.jsixface.common.Settings -class SettingsApi { +class SettingsApi(private val watchers: Watchers) { private val logger = logger() fun getSettings(): Settings = SavedData.load().settings @@ -13,6 +14,11 @@ class SettingsApi { val savedData = SavedData.load() val oldSettings = savedData.settings logger.info("Replacing old settings $oldSettings with new $settings") + settings.watchDuration?.let { + if (it != oldSettings.watchDuration) { + watchers.startWatching(it) + } + } ?: watchers.stopWatching() savedData.copy(settings = settings).save() } } \ No newline at end of file diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/api/VideoApi.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/api/VideoApi.kt index 0a8c15f..565f184 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/api/VideoApi.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/api/VideoApi.kt @@ -1,9 +1,18 @@ package io.github.jsixface.codexvert.api +import io.github.jsixface.codexvert.logger +import io.github.jsixface.codexvert.utils.CodecUtils.parseMediaInfo import io.github.jsixface.common.TrackType import io.github.jsixface.common.VideoFile -import io.github.jsixface.codexvert.utils.CodecUtils.parseMediaInfo -import kotlin.io.path.* +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.Path +import kotlin.io.path.PathWalkOption +import kotlin.io.path.extension +import kotlin.io.path.getLastModifiedTime +import kotlin.io.path.isDirectory +import kotlin.io.path.name +import kotlin.io.path.pathString +import kotlin.io.path.walk typealias VideoList = Map @@ -11,7 +20,9 @@ typealias VideoList = Map @OptIn(ExperimentalPathApi::class) class VideoApi { - fun refreshDirs() { + private val logger = logger() + + fun refreshDirs(): Boolean { val data = SavedData.load() val scan = mutableMapOf() data.settings.libraryLocations.forEach { l -> @@ -19,16 +30,21 @@ class VideoApi { loc.walk(PathWalkOption.FOLLOW_LINKS).forEach { p -> if (p.isDirectory().not() && data.settings.videoExtensions.contains(p.extension.lowercase())) { scan[p.pathString] = VideoFile( - path = p.pathString, - fileName = p.name, - modifiedTime = p.getLastModifiedTime().toMillis() + path = p.pathString, + fileName = p.name, + modifiedTime = p.getLastModifiedTime().toMillis() ) } } } val toParse: VideoList = consolidateData(data.details, scan) + val deleted = removeDeleted(data.details, scan) parseMediaFiles(data.details, toParse) - data.save() + return if (toParse.isNotEmpty() || deleted) { + logger.info("Saving new data") + data.save() + true + } else false } fun getVideos(): VideoList = SavedData.load().details @@ -37,9 +53,9 @@ class VideoApi { toParse.values.forEach { videoFile -> parseMediaInfo(videoFile.path)?.let { tracks -> details[videoFile.path] = videoFile.copy( - videos = tracks.filter { t -> t.type == TrackType.Video }, - audios = tracks.filter { t -> t.type == TrackType.Audio }, - subtitles = tracks.filter { t -> t.type == TrackType.Subtitle }, + videos = tracks.filter { t -> t.type == TrackType.Video }, + audios = tracks.filter { t -> t.type == TrackType.Audio }, + subtitles = tracks.filter { t -> t.type == TrackType.Subtitle }, ) } } @@ -47,9 +63,13 @@ class VideoApi { private fun consolidateData(data: MutableMap, scan: VideoList): VideoList { val toScan = scan.filterValues { data[it.path]?.modifiedTime != it.modifiedTime } - toScan.forEach { data[it.key] = it.value } + toScan.forEach { data[it.key] = it.value } // Source object change + return toScan + } + + private fun removeDeleted(data: MutableMap, scan: VideoList): Boolean { val toDelete = data.keys.filter { !scan.containsKey(it) } toDelete.forEach { data.remove(it) } - return toScan + return toDelete.isNotEmpty() } } \ No newline at end of file diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Koin.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Koin.kt index 5a25a50..204902c 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Koin.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Koin.kt @@ -1,6 +1,10 @@ package io.github.jsixface.codexvert.plugins -import io.github.jsixface.codexvert.api.* +import io.github.jsixface.codexvert.api.BackupApi +import io.github.jsixface.codexvert.api.ConversionApi +import io.github.jsixface.codexvert.api.JobsApi +import io.github.jsixface.codexvert.api.SettingsApi +import io.github.jsixface.codexvert.api.VideoApi import org.koin.core.context.startKoin import org.koin.dsl.module import org.koin.logger.slf4jLogger @@ -13,9 +17,10 @@ fun configureKoin() { } private val koinModule = module { - single { SettingsApi() } single { VideoApi() } single { BackupApi() } - single { ConversionApi(settingsApi = get()) } + single { ConversionApi() } single { JobsApi(conversionApi = get()) } + single(createdAtStart = true) { Watchers(videoApi = get(), conversionApi = get()) } + single { SettingsApi(watchers = get()) } } diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Watchers.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Watchers.kt index d12f9e0..b5755be 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Watchers.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Watchers.kt @@ -1,59 +1,69 @@ package io.github.jsixface.codexvert.plugins +import io.github.jsixface.codexvert.api.ConversionApi import io.github.jsixface.codexvert.api.SavedData import io.github.jsixface.codexvert.api.VideoApi import io.github.jsixface.codexvert.logger -import io.ktor.server.application.Application -import io.ktor.server.application.ApplicationStopped -import io.ktor.server.application.ApplicationStopping -import java.io.File -import java.nio.file.Path -import java.nio.file.Paths -import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE -import java.nio.file.StandardWatchEventKinds.ENTRY_DELETE -import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY -import kotlin.io.path.pathString +import io.github.jsixface.common.Codec +import io.github.jsixface.common.Conversion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koin.ktor.ext.inject +import kotlin.time.Duration +class Watchers(private val videoApi: VideoApi, private val conversionApi: ConversionApi) { + private val logger = logger() + private var watchingJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) -fun Application.configureWatchers() { - - val videoApi by inject() - val logger = logger() - - val savedData = SavedData.load() - val firstDir = savedData.settings.libraryLocations.firstOrNull() ?: return - try { - val watchService = Paths.get(firstDir).fileSystem.newWatchService() - monitor.subscribe(ApplicationStopping) { watchService.close() } - - savedData.settings.libraryLocations.forEach { - Paths.get(it).register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) + init { + // Start watching if the settings allow it + val savedData = SavedData.load() + savedData.settings.watchDuration?.let { duration -> + startWatching(duration) } - CoroutineScope(Dispatchers.Default).launch { - // Start the infinite polling loop - while (isActive) { - val key = withContext(Dispatchers.IO) { watchService.take() } - val directory = (key.watchable() as? Path) ?: continue - for (event in key.pollEvents()) { - val path = (event.context() as? Path) ?: continue - val eventFile = File(directory.toFile(), path.pathString) - logger.info("file: ${eventFile.absolutePath} has event ${event.kind()}") - videoApi.refreshDirs() - } - if (!key.reset()) { - // Don't have the access to listen on this directory anymore. - monitor.raise(ApplicationStopped, this@configureWatchers) - break // loop + } + + fun startWatching(interval: Duration) { + stopWatching() + logger.info("Starting the watcher") + watchingJob = scope.launch { + try { + while (isActive) { + delay(interval) + val changes = videoApi.refreshDirs() + if (changes) { + logger.info("Changes detected, refreshing data") + processChanges() + } } + } catch (e: Exception) { + logger.error("Whoops!!", e) } } - } catch (e: Exception) { - logger.error("Whoops!!", e) + } + + private fun processChanges() { + // Convert the files that has EAC3 or AC3 codec to AAC codec. + // Make sure those files are not already in the job queue. + val files = videoApi.getVideos() + files.values.forEach { videoFile -> + val dolbyTracks = videoFile.audios.filter { it.codec.lowercase() in listOf("eac3", "ac3") } + val job = conversionApi.jobs.find { it.videoFile.fileName == videoFile.fileName } + if (dolbyTracks.isNotEmpty() && job == null) { + val conversionSpecs = dolbyTracks.map { Pair(it, Conversion.Convert(Codec.AAC)) } + logger.info("Auto Converting for ${videoFile.fileName}") + conversionApi.startConversion(videoFile, conversionSpecs) + } + } + } + + fun stopWatching() { + logger.info("Stopping the watcher") + watchingJob?.cancel() } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/github/jsixface/common/Settings.kt b/shared/src/commonMain/kotlin/io/github/jsixface/common/Settings.kt index 65fd0eb..02ad936 100644 --- a/shared/src/commonMain/kotlin/io/github/jsixface/common/Settings.kt +++ b/shared/src/commonMain/kotlin/io/github/jsixface/common/Settings.kt @@ -1,10 +1,12 @@ package io.github.jsixface.common import kotlinx.serialization.Serializable +import kotlin.time.Duration @Serializable data class Settings( - val libraryLocations: List = listOf(), - val workspaceLocation: String = "/tmp/vid-con", - val videoExtensions: List = listOf("avi", "mp4", "mkv", "mpeg4") + val libraryLocations: List = listOf(), + val workspaceLocation: String = "/tmp/vid-con", + val videoExtensions: List = listOf("avi", "mp4", "mkv", "mpeg4"), + val watchDuration: Duration? = null, ) \ No newline at end of file