From f1ef78ed8f5534f2afbb6936e258b9e6f0b17ff6 Mon Sep 17 00:00:00 2001 From: Arumugam J Date: Fri, 17 Jan 2025 21:13:12 -0800 Subject: [PATCH] Added logic for db access and storage --- gradle/libs.versions.toml | 1 - server/build.gradle.kts | 2 + .../io/github/jsixface/codexvert/db/Dto.kt | 15 +-- .../jsixface/codexvert/db/VideoFilesRepo.kt | 102 ++++++++++++++++++ .../jsixface/codexvert/ffprobe/Parser.kt | 50 +++++++++ .../jsixface/codexvert/ffprobe/ProbeInfo.kt | 39 +++++++ .../jsixface/codexvert/ffprobe/ProbeUtils.kt | 36 +++++++ .../github/jsixface/codexvert/plugins/Koin.kt | 9 +- .../jsixface/codexvert/plugins/Routing.kt | 11 +- .../jsixface/codexvert/utils/AspectRatio.kt | 21 ++++ .../codexvert/utils/TypeConverters.kt | 38 +++++++ .../changelog-001-create-basic-tables.yaml | 10 +- 12 files changed, 320 insertions(+), 14 deletions(-) create mode 100644 server/src/main/kotlin/io/github/jsixface/codexvert/db/VideoFilesRepo.kt create mode 100644 server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/Parser.kt create mode 100644 server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeInfo.kt create mode 100644 server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeUtils.kt create mode 100644 server/src/main/kotlin/io/github/jsixface/codexvert/utils/AspectRatio.kt create mode 100644 server/src/main/kotlin/io/github/jsixface/codexvert/utils/TypeConverters.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06cd9b3..8666a1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,7 +62,6 @@ database = [ "exposed-jdbc", "exposed-json", "liquibase-core", - "sqlite" ] koin-server = [ "koin-core", diff --git a/server/build.gradle.kts b/server/build.gradle.kts index b9b20f0..6e31d75 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { implementation(libs.kotlinx.json) implementation(libs.bundles.koin.server) + runtimeOnly(libs.sqlite) + // testImplementation(libs.ktor.server.tests) testImplementation(libs.kotlin.test.junit) testImplementation(libs.bundles.koin.test) diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/db/Dto.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/db/Dto.kt index fdf4910..0b77671 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/db/Dto.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/db/Dto.kt @@ -34,16 +34,16 @@ class VideoEntity(id: EntityID) : IntEntity(id) { var bitRate by VideosTable.bitRate var bitDepth by VideosTable.bitDepth var pixelFormat by VideosTable.pixelFormat - val videoFile by VideoFileEntity referrersOn VideosTable.videoFile + var videoFile by VideoFileEntity referencedOn VideosTable.videoFile } object AudiosTable : IntIdTable() { val index = integer("index") val codec = varchar("codec", 50) - val channels = varchar("channels", 50) + val channels = integer("channels") val layout = varchar("layout", 50) val bitrate = integer("bit_rate") - val sampleRate = integer("sample_rate") + val sampleRate = varchar("sample_rate", 25) val language = varchar("language", 50) val videoFile = reference("video_file_id", VideoFilesTable, onDelete = ReferenceOption.CASCADE) } @@ -58,7 +58,7 @@ class AudioEntity(id: EntityID) : IntEntity(id) { var bitrate by AudiosTable.bitrate var sampleRate by AudiosTable.sampleRate var language by AudiosTable.language - val videoFile by VideoFileEntity referrersOn AudiosTable.videoFile + var videoFile by VideoFileEntity referencedOn AudiosTable.videoFile } object SubtitlesTable : IntIdTable() { @@ -74,7 +74,7 @@ class SubtitleEntity(id: EntityID) : IntEntity(id) { var index by SubtitlesTable.index var codec by SubtitlesTable.codec var language by SubtitlesTable.language - val videoFile by VideoFileEntity referrersOn SubtitlesTable.videoFile + var videoFile by VideoFileEntity referencedOn SubtitlesTable.videoFile } object VideoFilesTable : IntIdTable() { @@ -90,7 +90,10 @@ class VideoFileEntity(id: EntityID) : IntEntity(id) { var path by VideoFilesTable.path var name by VideoFilesTable.name - val sizeMb by VideoFilesTable.sizeMb + var sizeMb by VideoFilesTable.sizeMb + val videoStream by VideoEntity backReferencedOn VideosTable.videoFile + val audioStreams by AudioEntity referrersOn AudiosTable.videoFile + val subtitles by SubtitleEntity referrersOn SubtitlesTable.videoFile var modified by VideoFilesTable.modified var added by VideoFilesTable.added } \ 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 new file mode 100644 index 0000000..f3ce3e8 --- /dev/null +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/db/VideoFilesRepo.kt @@ -0,0 +1,102 @@ +package io.github.jsixface.codexvert.db + +import io.github.jsixface.codexvert.ffprobe.ProbeInfo +import io.github.jsixface.codexvert.ffprobe.ProbeStream +import io.github.jsixface.codexvert.logger +import io.github.jsixface.codexvert.utils.updateInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.datetime.Clock +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import java.nio.file.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.fileSize +import kotlin.io.path.getLastModifiedTime + + +interface IVideoFilesRepo { + suspend fun getAll(): List + suspend fun get(id: Int): VideoFileEntity? + suspend fun getFile(path: Path): VideoFileEntity? + suspend fun delete(id: Int) + suspend fun update(videoInfo: ProbeInfo, entity: VideoFileEntity): Boolean + suspend fun create(videoInfo: ProbeInfo, file: Path): VideoFileEntity +} + +class VideoFilesRepo(private val db: Database) : IVideoFilesRepo { + private val logger = logger() + + private suspend fun dbQuery(block: suspend () -> T): T = + newSuspendedTransaction(Dispatchers.IO) { block() } + + override suspend fun getAll() = dbQuery { VideoFileEntity.all().toList() } + + override suspend fun get(id: Int) = dbQuery { VideoFileEntity.findById(id) } + + override suspend fun getFile(path: Path) = dbQuery { + VideoFileEntity.find { VideoFilesTable.path eq path.toAbsolutePath().toString() }.firstOrNull() + } + + override suspend fun create(videoInfo: ProbeInfo, file: Path): VideoFileEntity { + return dbQuery { + val v = VideoFileEntity.new { + path = file.absolutePathString() + name = file.fileName.toString() + sizeMb = file.fileSize().toInt() / 1024 / 1024 + modified = file.getLastModifiedTime().toMillis() + added = Clock.System.now().toEpochMilliseconds() + } + logger.debug("Added video file: ${v.path}") + videoInfo.streams.forEach { createStream(it, v) } + v + } + } + + private fun createStream(stream: ProbeStream, v: VideoFileEntity) { + when (stream.codecType) { + "audio" -> { + AudioEntity.new { + videoFile = v + updateInfo(stream) + } + } + + "video" -> { + VideoEntity.new { + videoFile = v + updateInfo(stream) + } + } + + "subtitle" -> { + SubtitleEntity.new { + videoFile = v + index = stream.index + codec = stream.codecName + language = stream.tags["language"] ?: "" + } + } + } + } + + + override suspend fun delete(id: Int) { + dbQuery { + val v = get(id) + logger.debug("deleting video file: ${v?.path}") + v?.delete() + } + } + + override suspend fun update(videoInfo: ProbeInfo, entity: VideoFileEntity) = dbQuery { + try { + entity.videoStream.delete() + entity.audioStreams.forEach { it.delete() } + entity.subtitles.forEach { it.delete() } + videoInfo.streams.forEach { createStream(it, entity) } + true + } catch (e: Exception) { + false + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..d62eb72 --- /dev/null +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/Parser.kt @@ -0,0 +1,50 @@ +package io.github.jsixface.codexvert.ffprobe + +import io.github.jsixface.codexvert.db.IVideoFilesRepo +import io.github.jsixface.codexvert.logger +import java.nio.file.Path +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.walk + + +interface IParser { + suspend fun parseVideoFile(file: Path) + suspend fun parseAll(locations: List, extensions: List) +} + +class Parser(private val repo: IVideoFilesRepo) : IParser { + private val logger = logger() + + override suspend fun parseVideoFile(file: Path) { + val p = ProbeUtils.parseMediaInfo(file) ?: return + val entity = repo.getFile(file) + if (entity == null) { + repo.create(p, file) + } else { + repo.update(p, entity) + } + } + + override suspend fun parseAll(locations: List, extensions: List) { + val videos = locations.map { Path(it) }.flatMap { loc -> + loc.walk(PathWalkOption.FOLLOW_LINKS) + .filter { it.isDirectory().not() } + .filter { extensions.contains(it.extension.lowercase()) } + }.associateWith { it.getLastModifiedTime().toMillis() } + .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)) } + modified.keys.forEach { parseVideoFile(Path(it)) } + deleted.values.forEach { repo.delete(it.id.value) } + } + +} \ No newline at end of file diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeInfo.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeInfo.kt new file mode 100644 index 0000000..10c9ef4 --- /dev/null +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeInfo.kt @@ -0,0 +1,39 @@ +package io.github.jsixface.codexvert.ffprobe + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProbeStream( + val index: Int, + @SerialName("codec_name") val codecName: String = "", + @SerialName("codec_type") val codecType: String, + @SerialName("codec_tag_string") val codecTagName: String = "", + val profile: String = "", + val width: Int = 0, + val height: Int = 0, + @SerialName("display_aspect_ratio") val aspectRatio: String = "", + @SerialName("avg_frame_rate") val frameRate: String = "", + val bitRate: String = "", + val bitDepth: String = "", + @SerialName("pix_fmt") val pixelFormat: String = "", + + val channels: Int = 1, + @SerialName("channel_layout") val channelLayout: String = "", + @SerialName("sample_rate") val sampleRate: String = "", + @SerialName("tags") val tags: Map = emptyMap(), +) + +@Serializable +data class ProbeFormat( + @SerialName("filename") val fileName: String, + @SerialName("nb_streams") val numStreams: Int, + @SerialName("format_long_name") val formatName: String, + @SerialName("duration") val duration: String = "", +) + +@Serializable +data class ProbeInfo( + val streams: List, + val format: ProbeFormat, +) \ 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 new file mode 100644 index 0000000..6253ea7 --- /dev/null +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/ffprobe/ProbeUtils.kt @@ -0,0 +1,36 @@ +package io.github.jsixface.codexvert.ffprobe + +import io.github.jsixface.codexvert.logger +import kotlinx.serialization.json.Json +import java.nio.file.Path + +object ProbeUtils { + private val logger = logger() + private val json = Json { + ignoreUnknownKeys = true + } + + fun parseMediaInfo(path: Path): ProbeInfo? { + logger.info("Parsing file $path") + val builder = + ProcessBuilder( + "ffprobe", + "-v", "error", + "-of", "json", + "-pretty", + "-show_entries", "stream:program:format:chapter", + path.toAbsolutePath().toString(), + ) + return try { + val process = builder.start() + val 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) + 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 526b01e..692ab2b 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 @@ -5,15 +5,22 @@ 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 io.github.jsixface.codexvert.db.IVideoFilesRepo +import io.github.jsixface.codexvert.db.VideoFilesRepo import io.github.jsixface.codexvert.db.getDb +import io.github.jsixface.codexvert.ffprobe.IParser +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.dsl.module import org.koin.logger.slf4jLogger fun Application.configureKoin() { val koinModule = module { - single { getDb() } + single { getDb() } + single { VideoFilesRepo(get()) } + single { Parser(get()) } single { VideoApi() } single { BackupApi() } single { ConversionApi() } diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Routing.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Routing.kt index 3bf1696..d26d662 100644 --- a/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Routing.kt +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/plugins/Routing.kt @@ -1,13 +1,14 @@ package io.github.jsixface.codexvert.plugins +import io.github.jsixface.codexvert.route.backupRoutes import io.github.jsixface.codexvert.route.jobRoutes import io.github.jsixface.codexvert.route.videoRoutes -import io.github.jsixface.codexvert.route.backupRoutes import io.github.jsixface.route.settingsRoutes -import io.ktor.server.application.* -import io.ktor.server.http.content.* -import io.ktor.server.resources.* -import io.ktor.server.routing.* +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.http.content.staticFiles +import io.ktor.server.resources.Resources +import io.ktor.server.routing.routing import java.io.File fun Application.configureRouting() { diff --git a/server/src/main/kotlin/io/github/jsixface/codexvert/utils/AspectRatio.kt b/server/src/main/kotlin/io/github/jsixface/codexvert/utils/AspectRatio.kt new file mode 100644 index 0000000..7b59829 --- /dev/null +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/utils/AspectRatio.kt @@ -0,0 +1,21 @@ +package io.github.jsixface.codexvert.utils + +class AspectRatio(private val w: Float, private val h: Float) { + + override fun toString(): String { + val (wi, hi) = when { + w < 10 || h < 10 -> w to h + w > h -> w / h to 1.0f + else -> 1.0f to h / w + } + return "${wi.shortString()}:${hi.shortString()}" + } + + companion object { + operator fun invoke(ratio: String): AspectRatio { + val measures = ratio.split(":").mapNotNull { it.toFloatOrNull() } + assert(measures.size == 2) { "Should be in the format 'w:h" } + return AspectRatio(measures[0], measures[1]) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..809166c --- /dev/null +++ b/server/src/main/kotlin/io/github/jsixface/codexvert/utils/TypeConverters.kt @@ -0,0 +1,38 @@ +package io.github.jsixface.codexvert.utils + +import io.github.jsixface.codexvert.db.AudioEntity +import io.github.jsixface.codexvert.db.VideoEntity +import io.github.jsixface.codexvert.ffprobe.ProbeStream + +fun VideoEntity.updateInfo(stream: ProbeStream) { + index = stream.index + codec = stream.codecName + codecTag = stream.codecTagName + profile = stream.profile + pixelFormat = stream.pixelFormat + resolution = "${stream.width}x${stream.height}" + aspectRatio = AspectRatio(stream.aspectRatio).toString() + frameRate = stream.frameRate.let { + if (it.contains('/').not()) it.toFloatOrNull() else { + val (f, s) = it.split("/").mapNotNull { x -> x.toFloatOrNull() } + (f / s) + } + } ?: 0f + + bitRate = stream.bitRate.toIntOrNull() ?: 0 + bitDepth = 0 +} + +fun AudioEntity.updateInfo(stream: ProbeStream) { + index = stream.index + codec = stream.codecName + channels = stream.channels + layout = stream.channelLayout + sampleRate = stream.sampleRate + language = stream.tags["language"] ?: "" + bitrate = stream.bitRate.toIntOrNull() ?: 0 +} + + +fun Float.shortString() = if (this == toInt().toFloat()) this.toInt().toString() else "%.2f".format(this) + diff --git a/server/src/main/resources/db/changelog/changes/changelog-001-create-basic-tables.yaml b/server/src/main/resources/db/changelog/changes/changelog-001-create-basic-tables.yaml index 3bb1a7d..bf3ccac 100644 --- a/server/src/main/resources/db/changelog/changes/changelog-001-create-basic-tables.yaml +++ b/server/src/main/resources/db/changelog/changes/changelog-001-create-basic-tables.yaml @@ -34,6 +34,14 @@ databaseChangeLog: - column: name: added type: bigint + + - createIndex: + tableName: VideoFiles + indexName: idx_video_path + columns: + - column: + name: path + - createTable: tableName: Subtitles columns: @@ -89,7 +97,7 @@ databaseChangeLog: type: int - column: name: sample_rate - type: int + type: varchar(25) - column: name: language type: varchar(50)