diff --git a/.attach_pid3511 b/.attach_pid3511 deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt index 8a8e10c..8d21ada 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt @@ -42,7 +42,7 @@ class IosConnector( private val log = LoggerFactory.getLogger(IosConnector::class.java) private val json = Json { ignoreUnknownKeys = true } - private val pythonBin: String = pythonBinOverride ?: discoverPython() + internal val pythonBin: String = pythonBinOverride ?: discoverPython() companion object { private val PIPX_PYTHON_CANDIDATES = listOf( @@ -67,7 +67,7 @@ class IosConnector( } /** Path to the bundled helper script, extracted to a temp location on first use */ - private val helperScript: String by lazy { extractHelperScript() } + internal val helperScript: String by lazy { extractHelperScript() } override suspend fun detectDevices(): List { return try { diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index 7944b3a..848cca7 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -157,6 +157,13 @@ class AppState(private val configStore: ConfigStore) { // Default: select the first camera-roll folder val defaultFolder = folders.firstOrNull { it.isCameraRoll }?.name selectFolder(defaultFolder) + + // For iOS: batch pre-fetch thumbnails in background (amortises Python startup cost) + if (device.type == DeviceType.IOS && connector is com.bolenpad.photophetch.device.IosConnector) { + launch(Dispatchers.IO) { + prefetchIosThumbnails(device, raw, connector) + } + } } catch (e: Exception) { log.error("Photo listing failed", e) _photoList.value = PhotoListState.Error(e.message ?: "Unknown error") @@ -164,6 +171,71 @@ class AppState(private val configStore: ConfigStore) { } } + private suspend fun prefetchIosThumbnails( + device: DeviceInfo, + photos: List, + connector: com.bolenpad.photophetch.device.IosConnector, + ) { + val cacheKey = connector.cacheKey(device) + val cacheRoot = java.nio.file.Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache") + val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_")) + if (!cacheDir.toFile().exists()) cacheDir.toFile().mkdirs() + val tmpDir = java.nio.file.Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-thumbs") + if (!tmpDir.toFile().exists()) tmpDir.toFile().mkdirs() + + // Only fetch photos not already cached + val needed = photos.filter { photo -> + val ext = photo.filename.substringAfterLast(".", "").lowercase() + if (ext in setOf("mp4", "mov", "3gp", "avi", "mkv", "m4v", "webm")) return@filter false + val hash = (cacheKey + photo.devicePath).hashCode().toString(16) + val cachePath = cacheDir.resolve("$hash.jpg") + !cachePath.toFile().exists() || cachePath.toFile().length() == 0L + } + + if (needed.isEmpty()) return + log.info("Pre-fetching ${needed.size} iOS thumbnails...") + + // Build pairs JSON and run batch-thumbs command + needed.chunked(30).forEach { batch -> + val pairs = batch.joinToString(",") { photo -> + val ext = photo.filename.substringAfterLast(".", "").lowercase() + val hash = (cacheKey + photo.devicePath).hashCode().toString(16) + val tmpPath = tmpDir.resolve("$hash.$ext") + val src = photo.devicePath.replace("\"", "\\\"") + val dst = tmpPath.toString().replace("\"", "\\\"") + "[\"$src\",\"$dst\"]" + } + val pairsJson = "[$pairs]" + + try { + val process = ProcessBuilder(connector.pythonBin, connector.helperScript, "batch-thumbs", pairsJson) + .redirectErrorStream(false).start() + process.inputStream.readBytes() // stdout + process.errorStream.readBytes() // stderr + process.waitFor() + + // Run sips on each successfully pulled file + batch.forEach { photo -> + val ext = photo.filename.substringAfterLast(".", "").lowercase() + val hash = (cacheKey + photo.devicePath).hashCode().toString(16) + val tmpFile = tmpDir.resolve("$hash.$ext") + val cachePath = cacheDir.resolve("$hash.jpg") + if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) { + val sips = ProcessBuilder("sips", "--resampleWidth", "240", + tmpFile.toString(), "--out", cachePath.toString()) + .redirectErrorStream(true).start() + sips.inputStream.readBytes() + sips.waitFor() + tmpFile.toFile().delete() + } + } + } catch (e: Exception) { + log.warn("iOS thumbnail batch failed: ${e.message}") + } + } + log.info("iOS thumbnail pre-fetch complete") + } + fun selectFolder(folderName: String?) { _selectedFolder.value = folderName _selectedPhotos.value = emptySet()