diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 690eb66..3c6cf5f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -49,6 +49,7 @@ kotlin { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) implementation(libs.ktor.client.cio) + implementation(libs.logback) } wasmJsMain.dependencies { implementation(libs.ktor.client.js) diff --git a/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt index 856af0a..1d7e7f1 100644 --- a/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt @@ -100,21 +100,23 @@ fun HomeScreen() { value = navigator.scaffoldValue, listPane = { AnimatedPane { - if (loading) CircularProgressIndicator(modifier = sidePad) - if (errorLoading) { - Text( - "Error loading list", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error - ) - } - PageContent(videoList, videoSelected = { - navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it) - }) { - scope.launch { - loading = true - viewModel.refresh() - load() + Column { + if (loading) CircularProgressIndicator(modifier = sidePad) + if (errorLoading) { + Text( + "Error loading list", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + } + PageContent(videoList, videoSelected = { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it) + }) { + scope.launch { + loading = true + viewModel.refresh() + load() + } } } } diff --git a/composeApp/src/desktopMain/resources/logback.xml b/composeApp/src/desktopMain/resources/logback.xml new file mode 100644 index 0000000..2f06544 --- /dev/null +++ b/composeApp/src/desktopMain/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + 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 2614eaf..9939526 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,73 +1,27 @@ package io.github.jsixface.codexvert.api +import io.github.jsixface.codexvert.db.IVideoFilesRepo +import io.github.jsixface.codexvert.ffprobe.IParser import io.github.jsixface.codexvert.logger -import io.github.jsixface.codexvert.utils.CodecUtils.parseMediaInfo -import io.github.jsixface.common.TrackType +import io.github.jsixface.codexvert.utils.toVideoFile import io.github.jsixface.common.VideoFile -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 +import java.nio.file.Path -typealias VideoList = Map +typealias VideoList = List - -class VideoApi { +class VideoApi(private val parser: IParser, private val repo: IVideoFilesRepo) { private val logger = logger() - fun refreshDirs(): Boolean { + suspend fun refreshDirs(): Boolean { + logger.info("Refreshing videos...") val data = SavedData.load() - val scan = mutableMapOf() - data.settings.libraryLocations.forEach { l -> - val loc = Path(l) - 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() - ) - } - } - } - val toParse: VideoList = consolidateData(data.details, scan) - val deleted = removeDeleted(data.details, scan) - parseMediaFiles(data.details, toParse) - return if (toParse.isNotEmpty() || deleted) { - logger.info("Saving new data") - data.save() - true - } else false + return parser.parseAll(data.settings.libraryLocations, data.settings.videoExtensions) } - fun getVideos(): VideoList = SavedData.load().details - - private fun parseMediaFiles(details: MutableMap, toParse: VideoList) { - 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 }, - ) - } - } - } - - 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 } // Source object change - return toScan - } + suspend fun getVideos(): VideoList = repo.getAll().map { it.toVideoFile() } - private fun removeDeleted(data: MutableMap, scan: VideoList): Boolean { - val toDelete = data.keys.filter { !scan.containsKey(it) } - toDelete.forEach { data.remove(it) } - return toDelete.isNotEmpty() + suspend fun getVideo(path: String): VideoFile? { + return repo.getFile(Path.of(path))?.toVideoFile() } } \ No newline at end of file diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/db/VideoFilesRepo.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/db/VideoFilesRepo.kt index f3ce3e8..00976e2 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/db/VideoFilesRepo.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/db/VideoFilesRepo.kt @@ -84,6 +84,9 @@ class VideoFilesRepo(private val db: Database) : IVideoFilesRepo { dbQuery { val v = get(id) logger.debug("deleting video file: ${v?.path}") + v?.videoStream?.delete() + v?.audioStreams?.forEach { it.delete() } + v?.subtitles?.forEach { it.delete() } v?.delete() } } diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/Parser.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/Parser.kt index d62eb72..34bb32a 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/Parser.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/Parser.kt @@ -13,7 +13,7 @@ import kotlin.io.path.walk interface IParser { suspend fun parseVideoFile(file: Path) - suspend fun parseAll(locations: List, extensions: List) + suspend fun parseAll(locations: List, extensions: List): Boolean } class Parser(private val repo: IVideoFilesRepo) : IParser { @@ -29,7 +29,7 @@ class Parser(private val repo: IVideoFilesRepo) : IParser { } } - override suspend fun parseAll(locations: List, extensions: List) { + override suspend fun parseAll(locations: List, extensions: List): Boolean { val videos = locations.map { Path(it) }.flatMap { loc -> loc.walk(PathWalkOption.FOLLOW_LINKS) .filter { it.isDirectory().not() } @@ -38,13 +38,12 @@ class Parser(private val repo: IVideoFilesRepo) : IParser { .mapKeys { it.key.toAbsolutePath().toString() } val entries = repo.getAll().associateBy { it.path } - val added = videos - entries.keys val deleted = entries - videos.keys - val modified = videos.filter { it.value != entries[it.key]?.modified } - added.keys - logger.info("Total files: ${videos.size}, Added: ${added.size}, Deleted: ${deleted.size}, Modified: ${modified.size}") - added.keys.forEach { parseVideoFile(Path(it)) } + val modified = videos.filter { it.value != entries[it.key]?.modified } + logger.info("Total files: ${videos.size}, Deleted: ${deleted.size}, Added/Modified: ${modified.size}") modified.keys.forEach { parseVideoFile(Path(it)) } deleted.values.forEach { repo.delete(it.id.value) } + return deleted.isNotEmpty() || modified.isNotEmpty() } } \ No newline at end of file diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeUtils.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeUtils.kt index 6253ea7..bfad022 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeUtils.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeUtils.kt @@ -21,15 +21,16 @@ object ProbeUtils { "-show_entries", "stream:program:format:chapter", path.toAbsolutePath().toString(), ) + var output = "" return try { val process = builder.start() - val output = process.inputStream.use { it.bufferedReader().readText() } + output = process.inputStream.use { it.bufferedReader().readText() } process.waitFor() val probeInfo = json.decodeFromString(output) logger.debug("Probe info: $probeInfo") probeInfo } catch (e: Exception) { - logger.error("Cant get file information for $path", e) + logger.error("Cant get file information for $path. Output = $output", e) null } } 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 692ab2b..3b28b61 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 @@ -13,20 +13,23 @@ import io.github.jsixface.codexvert.ffprobe.Parser import io.ktor.server.application.Application import org.jetbrains.exposed.sql.Database import org.koin.core.context.startKoin +import org.koin.core.module.dsl.createdAtStart +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind import org.koin.dsl.module import org.koin.logger.slf4jLogger fun Application.configureKoin() { val koinModule = module { single { getDb() } - single { VideoFilesRepo(get()) } - single { Parser(get()) } - single { VideoApi() } - single { BackupApi() } - single { ConversionApi() } - single { JobsApi(conversionApi = get()) } - single(createdAtStart = true) { Watchers(videoApi = get(), conversionApi = get()) } - single { SettingsApi(watchers = get()) } + singleOf(::VideoFilesRepo) bind IVideoFilesRepo::class + singleOf(::Parser) bind IParser::class + singleOf(::VideoApi) + singleOf(::BackupApi) + singleOf(::ConversionApi) + singleOf(::JobsApi) + singleOf(::SettingsApi) + singleOf(::Watchers) { createdAtStart() } } startKoin { 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 620c6fd..4012fec 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 @@ -3,7 +3,9 @@ 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.db.IVideoFilesRepo import io.github.jsixface.codexvert.logger +import io.github.jsixface.codexvert.utils.toVideoFile import io.github.jsixface.common.Codec import io.github.jsixface.common.Conversion import io.github.jsixface.common.isDolby @@ -16,7 +18,11 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.time.Duration -class Watchers(private val videoApi: VideoApi, private val conversionApi: ConversionApi) { +class Watchers( + private val videoApi: VideoApi, + private val repo: IVideoFilesRepo, + private val conversionApi: ConversionApi +) { private val logger = logger() private var watchingJob: Job? = null private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -48,11 +54,11 @@ class Watchers(private val videoApi: VideoApi, private val conversionApi: Conver } } - private fun processChanges() { + private suspend 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 files = repo.getAll().map { it.toVideoFile() } + files.forEach { videoFile -> val dolbyTracks = videoFile.audios.filter { it.isDolby() } val job = conversionApi.jobs.find { it.videoFile.fileName == videoFile.fileName } if (dolbyTracks.isNotEmpty() && job == null) { diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/route/VideoRoutes.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/route/VideoRoutes.kt index e0188b0..a3e1655 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/route/VideoRoutes.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/route/VideoRoutes.kt @@ -25,18 +25,18 @@ fun Route.videoRoutes() { val conversionApi by inject() get { - call.respond(videoApi.getVideos().values.toList().sortedBy { it.fileName }) + call.respond(videoApi.getVideos().sortedBy { it.fileName }) } patch { videoApi.refreshDirs() - call.respond(videoApi.getVideos().values.toList().sortedBy { it.fileName }) + call.respond(videoApi.getVideos().sortedBy { it.fileName }) } get { video -> logger.info("Getting video ${video.path}") video.path?.let { - val find = videoApi.getVideos().values.find { v -> v.fileName == it } + val find = videoApi.getVideo(it) logger.info("found = $find") call.respondNullable(find) } ?: run { @@ -45,13 +45,13 @@ fun Route.videoRoutes() { } post { video -> - val fileName = video.path ?: run { + val videoFilePath = video.path ?: run { logger.warn("no path in URL") call.respond(HttpStatusCode.BadRequest) return@post } - val videoFile = videoApi.getVideos().values.find { it.fileName == fileName } ?: run { - logger.warn("No videos found by name $fileName") + val videoFile = videoApi.getVideo(videoFilePath) ?: run { + logger.warn("No videos found by name $videoFilePath") call.respond(HttpStatusCode.BadRequest) return@post } diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/utils/CodecUtils.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/utils/CodecUtils.kt deleted file mode 100644 index 47f4153..0000000 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/utils/CodecUtils.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.github.jsixface.codexvert.utils - -import io.github.jsixface.codexvert.logger -import io.github.jsixface.common.MediaProbeInfo -import io.github.jsixface.common.MediaTrack -import io.github.jsixface.common.TrackType -import kotlinx.serialization.json.Json - -object CodecUtils { - private val logger = logger() - private val json = Json { - ignoreUnknownKeys = true - } - - fun parseMediaInfo(path: String): List? { - logger.info("Parsing file $path") - val builder = - ProcessBuilder("ffprobe", "-v", "error", "-show_entries", "stream", "-pretty", "-of", "json", path) - val probeInfo = runCatching { - val process = builder.start() - val output = process.inputStream.use { it.bufferedReader().readText() } - process.waitFor() - json.decodeFromString(output) - } - probeInfo.exceptionOrNull()?.let { logger.error("Cant get ") } - return probeInfo.getOrNull()?.streams?.mapNotNull { s -> - val trackType = when (s.codecType) { - "audio" -> TrackType.Audio - "video" -> TrackType.Video - "subtitle" -> TrackType.Subtitle - else -> null - } - trackType?.let { MediaTrack(trackType, s.index, s.codecName) } - } - } -} diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/utils/TypeConverters.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/utils/TypeConverters.kt index 809166c..ab6ab7a 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/utils/TypeConverters.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/utils/TypeConverters.kt @@ -1,8 +1,14 @@ package io.github.jsixface.codexvert.utils import io.github.jsixface.codexvert.db.AudioEntity +import io.github.jsixface.codexvert.db.SubtitleEntity import io.github.jsixface.codexvert.db.VideoEntity +import io.github.jsixface.codexvert.db.VideoFileEntity import io.github.jsixface.codexvert.ffprobe.ProbeStream +import io.github.jsixface.common.MediaTrack +import io.github.jsixface.common.TrackType +import io.github.jsixface.common.VideoFile +import org.jetbrains.exposed.sql.transactions.transaction fun VideoEntity.updateInfo(stream: ProbeStream) { index = stream.index @@ -36,3 +42,22 @@ fun AudioEntity.updateInfo(stream: ProbeStream) { fun Float.shortString() = if (this == toInt().toFloat()) this.toInt().toString() else "%.2f".format(this) +fun VideoFileEntity.toVideoFile(): VideoFile = transaction { + val videoTrack = videoStream.toMediaTrack() + val audioTracks = audioStreams.map { it.toMediaTrack() } + val subtitleTracks = subtitles.map { it.toMediaTrack() } + VideoFile( + path = path, + fileName = name, + modifiedTime = modified, + audios = audioTracks, + videos = listOf(videoTrack), + subtitles = subtitleTracks, + ) +} + +private fun VideoEntity.toMediaTrack() = MediaTrack(TrackType.Video, index, codec) + +private fun AudioEntity.toMediaTrack() = MediaTrack(TrackType.Audio, index, codec) + +private fun SubtitleEntity.toMediaTrack() = MediaTrack(TrackType.Subtitle, index, codec)