package com.estateunified.panel import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.UUID internal data class ListingVideoDto( val id: String = UUID.randomUUID().toString().take(12), val listingId: String, val ownerUserId: String, val storedName: String, val originalName: String = "", val mime: String = "video/mp4", val sizeBytes: Long = 0, val captionRu: String = "", val sortOrder: Int = 0, val uploadedAtIso: String = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(java.time.LocalDateTime.now()), ) internal class ListingVideosStore( private val gson: Gson, private val metaFile: Path, private val videosRoot: Path, ) { private val lock = Any() fun absolutePath(): String = metaFile.toAbsolutePath().normalize().toString() fun videosDir(): Path = videosRoot fun forListing(listingId: String): List = synchronized(lock) { load().filter { it.listingId == listingId }.sortedWith( compareBy { it.sortOrder }.thenBy { it.uploadedAtIso }, ) } fun get(id: String): ListingVideoDto? = synchronized(lock) { load().firstOrNull { it.id == id } } fun saveBytes( listingId: String, ownerUserId: String, originalName: String, mime: String, bytes: ByteArray, captionRu: String = "", ): ListingVideoDto { val ext = extOf(originalName, mime).lowercase() val storedName = "${UUID.randomUUID().toString().take(16)}.$ext" val dir = videosRoot.resolve(safeSegment(listingId)) Files.createDirectories(dir) Files.write(dir.resolve(storedName), bytes) synchronized(lock) { val list = load().toMutableList() val maxOrder = list.filter { it.listingId == listingId }.maxOfOrNull { it.sortOrder } ?: -1 val dto = ListingVideoDto( listingId = listingId, ownerUserId = ownerUserId, storedName = storedName, originalName = originalName, mime = mime, sizeBytes = bytes.size.toLong(), captionRu = captionRu, sortOrder = maxOrder + 1, ) list.add(0, dto) persist(list) return dto } } fun delete(id: String): Boolean = synchronized(lock) { val list = load().toMutableList() val idx = list.indexOfFirst { it.id == id } if (idx < 0) return@synchronized false val cur = list[idx] list.removeAt(idx) runCatching { Files.deleteIfExists(videosRoot.resolve(safeSegment(cur.listingId)).resolve(cur.storedName)) } persist(list) true } fun bytesOf(listingId: String, storedName: String): ByteArray? { val path = videosRoot.resolve(safeSegment(listingId)).resolve(safeSegment(storedName)) if (!Files.isRegularFile(path)) return null return runCatching { Files.readAllBytes(path) }.getOrNull() } fun pathOf(listingId: String, storedName: String): Path = videosRoot.resolve(safeSegment(listingId)).resolve(safeSegment(storedName)) fun mimeFor(storedName: String): String { val lc = storedName.lowercase() return when { lc.endsWith(".mp4") -> "video/mp4" lc.endsWith(".webm") -> "video/webm" lc.endsWith(".mov") -> "video/quicktime" lc.endsWith(".ogg") || lc.endsWith(".ogv") -> "video/ogg" else -> "application/octet-stream" } } private fun load(): MutableList { if (!Files.isRegularFile(metaFile)) return mutableListOf() val text = Files.readString(metaFile, StandardCharsets.UTF_8) if (text.isBlank()) return mutableListOf() return runCatching { gson.fromJson>(text, listType) ?: mutableListOf() }.getOrElse { mutableListOf() } } private fun persist(list: List) { Files.createDirectories(metaFile.toAbsolutePath().parent) val tmp = metaFile.resolveSibling(metaFile.fileName.toString() + ".tmp") Files.writeString(tmp, gson.toJson(list), StandardCharsets.UTF_8) Files.move(tmp, metaFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) } private fun extOf(originalName: String, mime: String): String { val dot = originalName.lastIndexOf('.') if (dot > 0 && dot < originalName.length - 1) { val ext = originalName.substring(dot + 1).lowercase() if (ext.length in 2..5 && ext.all { it.isLetterOrDigit() }) return ext } return when (mime.lowercase()) { "video/mp4" -> "mp4" "video/webm" -> "webm" "video/quicktime" -> "mov" "video/ogg" -> "ogv" else -> "mp4" } } private fun safeSegment(s: String): String = s.replace(Regex("[^A-Za-z0-9._-]"), "_").take(120) companion object { private val listType = object : TypeToken>() {}.type val allowedMimes: Set = setOf("video/mp4", "video/webm", "video/quicktime", "video/ogg") const val MAX_BYTES: Long = 80L * 1024L * 1024L } }