diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt index 8d21ada..a6108c8 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt @@ -121,6 +121,9 @@ class IosConnector( exifModel = device.displayName, mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO, sourceFolder = sourceFolder, + isLivePhotoMotion = obj["isLivePhotoMotion"]?.jsonPrimitive?.contentOrNull == "true", + isLivePhotoStill = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull != null, + companionDevicePath = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull, ) } catch (e: Exception) { log.debug("Failed to parse photo entry: ${e.message}") diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt b/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt index e8d1e6d..c6a69b0 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt @@ -19,6 +19,11 @@ data class ImportJob( val stripLivePhotoMotion: Boolean = true, /** Whether to delete photos from the phone after successful verification */ val deleteAfterVerify: Boolean = false, + /** + * Whether to import Live Photo .MOV companions alongside their HEIC stills. + * Default false — you said you don't want them. + */ + val importLivePhotoCompanions: Boolean = false, /** * Whether to preserve the device's subfolder structure under the staging folder. * e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt b/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt index 26cdd1e..735decd 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt @@ -26,6 +26,11 @@ data class PhonePhoto( val isLivePhotoStill: Boolean = false, /** True if this is the motion component of an Apple Live Photo (.MOV companion) */ val isLivePhotoMotion: Boolean = false, + /** + * Device path of the paired MOV companion (set on the HEIC when isLivePhotoStill=true). + * Used by the Copier to pull both files atomically and by deletePhoto to delete both. + */ + val companionDevicePath: String? = null, /** True if the format is HEIC — user may want to convert to JPEG */ val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) || filename.endsWith(".HEIF", ignoreCase = true), diff --git a/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt b/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt index 5c1ae72..ed3674e 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt @@ -63,14 +63,10 @@ class Copier( photosToImport.forEachIndexed { idx, photo -> onProgress(idx, photosToImport.size, photo.filename) - // Determine destination directory val destDir = if (job.preserveFolderStructure) { - // Always use sourceFolder regardless of how many folders are in the job val subFolder = photo.sourceFolder.ifBlank { - photo.devicePath - .substringAfter("/DCIM/", "") - .substringBeforeLast("/", "") - .ifBlank { "Camera" } + photo.devicePath.substringAfter("/DCIM/", "") + .substringBeforeLast("/", "").ifBlank { "Camera" } } stagingDir.resolve(subFolder).also { it.toFile().mkdirs() } } else { @@ -81,6 +77,23 @@ class Copier( if (result.error != null) { log.error("Failed to copy ${photo.filename}: ${result.error}") } + + // Pull Live Photo companion MOV if requested + if (job.importLivePhotoCompanions && photo.isLivePhotoStill && photo.companionDevicePath != null) { + val companionFilename = photo.companionDevicePath.substringAfterLast("/") + val companionPhoto = photo.copy( + devicePath = photo.companionDevicePath, + filename = companionFilename, + isLivePhotoStill = false, + isLivePhotoMotion = true, + companionDevicePath = null, + ) + val companionResult = copyOne(companionPhoto, destDir, convertHeic = false) + results.add(companionResult) + if (companionResult.error != null) { + log.error("Failed to copy companion ${companionFilename}: ${companionResult.error}") + } + } } onProgress(photosToImport.size, photosToImport.size, "") diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index 2233a4a..52064b7 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -350,6 +350,7 @@ class AppState(private val configStore: ConfigStore) { val stripLivePhotoMotion: Boolean, val deleteAfterVerify: Boolean, val preserveFolderStructure: Boolean, + val importLivePhotoCompanions: Boolean = false, val folderName: String, ) @@ -402,6 +403,7 @@ class AppState(private val configStore: ConfigStore) { stripLivePhotoMotion = importCfg.stripLivePhotoMotion, deleteAfterVerify = importCfg.deleteAfterVerify, preserveFolderStructure = importCfg.preserveFolderStructure, + importLivePhotoCompanions = importCfg.importLivePhotoCompanions, ) navigateTo(Screen.IMPORTING) @@ -428,8 +430,22 @@ class AppState(private val configStore: ConfigStore) { scope.launch(Dispatchers.IO) { val connector = connectorFor(device) result.results.filter { it.verified }.forEach { r -> - try { connector.deletePhoto(device, r.photo) } - catch (e: DeviceException) { log.error("Delete failed for ${r.photo.filename}: ${e.message}") } + try { + connector.deletePhoto(device, r.photo) + // Also delete companion MOV if this was a Live Photo still + if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) { + val companion = r.photo.copy( + devicePath = r.photo.companionDevicePath, + filename = r.photo.companionDevicePath.substringAfterLast("/"), + ) + try { connector.deletePhoto(device, companion) } + catch (e: DeviceException) { + log.warn("Could not delete companion for ${r.photo.filename}: ${e.message}") + } + } + } catch (e: DeviceException) { + log.error("Delete failed for ${r.photo.filename}: ${e.message}") + } } } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt index 2074b83..357c057 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -246,6 +246,13 @@ private fun DenseThumb( tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp)) } } + // LIVE badge for Live Photo stills + if (photo.isLivePhotoStill) { + Badge( + containerColor = MaterialTheme.colorScheme.secondary, + modifier = Modifier.align(Alignment.TopStart).padding(2.dp), + ) { Text("LIVE", style = MaterialTheme.typography.labelSmall) } + } if (photo.mediaType == MediaType.VIDEO) { val sizeLabel = if (photo.sizeBytes > 1024 * 1024) "▶ ${photo.sizeBytes / (1024 * 1024)}MB" diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt index d666f40..0af4962 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt @@ -145,10 +145,10 @@ fun ImportScreen(state: AppState) { onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } }, ) ToggleRow( - label = "Strip Live Photo motion clips", - description = "Excludes .MOV companions from Live Photos.", - checked = importConfig.stripLivePhotoMotion, - onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } }, + label = "Import Live Photo motion clips (.MOV)", + description = "Include the .MOV companion alongside each Live Photo still. Paired by filename match. Default off.", + checked = importConfig.importLivePhotoCompanions, + onCheckedChange = { state.updateImportConfig { copy(importLivePhotoCompanions = it) } }, ) ToggleRow( label = "Preserve folder structure", diff --git a/src/main/resources/scripts/photophetch-ios-helper.py b/src/main/resources/scripts/photophetch-ios-helper.py index a1df7a8..bc596f0 100644 --- a/src/main/resources/scripts/photophetch-ios-helper.py +++ b/src/main/resources/scripts/photophetch-ios-helper.py @@ -127,12 +127,40 @@ async def cmd_list_photos(): # Build a set of all filenames in this folder for Live Photo pairing all_filenames_in_folder = set(entries) + # Pre-compute Live Photo pairs: IMG_XXXX.MOV + IMG_XXXX.HEIC/JPG + # Key: base name (IMG_XXXX) -> MOV device path + live_photo_mov_paths = {} + for fname in entries: + if re.match(r'^IMG_\d{4}\.MOV$', fname, re.IGNORECASE): + base = fname.rsplit('.', 1)[0] + for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'): + if f'{base}.{still_ext}' in all_filenames_in_folder: + # Confirm it's small enough to be a Live Photo clip + # (we'll verify size when we hit the MOV entry) + live_photo_mov_paths[base] = f'{folder_path}/{fname}' + break + for filename in entries: ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' if ext not in MEDIA_EXTENSIONS: continue device_path = f'{folder_path}/{filename}' + base = filename.rsplit('.', 1)[0] + + # Determine Live Photo status + is_live_motion = False + companion_device_path = None + + if re.match(r'^IMG_\d{4}\.MOV$', filename, re.IGNORECASE): + if base in live_photo_mov_paths: + # This MOV is a companion — mark it but still include it + # (AppState will filter it from display) + is_live_motion = True + + elif re.match(r'^IMG_\d{4}\.(HEIC|HEIF|JPG|JPEG)$', filename, re.IGNORECASE): + if base in live_photo_mov_paths: + companion_device_path = live_photo_mov_paths[base] # Get file metadata via stat size_bytes = 0 @@ -162,6 +190,8 @@ async def cmd_list_photos(): 'sourceFolder': classify_iphone_file(filename, all_filenames_in_folder, size_bytes), 'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO', 'isHeic': ext in ('heic', 'heif'), + 'isLivePhotoMotion': is_live_motion, + 'companionDevicePath': companion_device_path, }) print(json.dumps(photos))