Replace gphoto2 with pymobiledevice3 for iOS connectivity
gphoto2 stopped working on iOS 17+ (Apple tightened PTP access). New approach uses pymobiledevice3 via AFC protocol — the same protocol Finder uses to browse the iPhone. - photophetch-ios-helper.py: bundled Python script (in JAR resources) that calls pymobiledevice3 AFC to list-devices, list-photos, pull, delete - IosConnector: rewritten to shell out to the helper script instead of gphoto2. Extracts the script from JAR to a temp file on first use. - AppConfig: gphoto2Path → python3Path - AppState: IosConnector now receives python3Path Prerequisites: brew install pipx && pipx install pymobiledevice3
This commit is contained in:
@@ -4,6 +4,16 @@ import com.bolenpad.photophetch.model.DeviceInfo
|
|||||||
import com.bolenpad.photophetch.model.DeviceType
|
import com.bolenpad.photophetch.model.DeviceType
|
||||||
import com.bolenpad.photophetch.model.MediaType
|
import com.bolenpad.photophetch.model.MediaType
|
||||||
import com.bolenpad.photophetch.model.PhonePhoto
|
import com.bolenpad.photophetch.model.PhonePhoto
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -13,215 +23,167 @@ import java.time.format.DateTimeFormatter
|
|||||||
import kotlin.io.path.absolutePathString
|
import kotlin.io.path.absolutePathString
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DeviceConnector implementation for iPhones via gphoto2.
|
* DeviceConnector implementation for iPhones via pymobiledevice3.
|
||||||
*
|
*
|
||||||
* Requires `gphoto2` on PATH (or override via [gphoto2Bin]).
|
* Requires pymobiledevice3 installed via pipx:
|
||||||
* Install via: brew install gphoto2
|
* brew install pipx && pipx install pymobiledevice3
|
||||||
*
|
*
|
||||||
* Important macOS note: The built-in Image Capture daemon (PTPCamera) will
|
* Uses a bundled Python helper script (photophetch-ios-helper.py) that
|
||||||
* conflict with gphoto2. If listing returns no files, run:
|
* calls the pymobiledevice3 AFC service to list and pull files.
|
||||||
* killall PTPCamera
|
|
||||||
* PhotoPhetch shows instructions for this when no photos are found on a
|
|
||||||
* connected iPhone.
|
|
||||||
*
|
*
|
||||||
* Live Photo handling: gphoto2 lists both the .HEIC and the companion .MOV
|
* Replaces the previous gphoto2-based implementation which stopped working
|
||||||
* with matching base names. We detect the pair by comparing filenames and
|
* on iOS 17+ due to Apple tightening PTP access.
|
||||||
* flag [PhonePhoto.isLivePhotoStill] / [PhonePhoto.isLivePhotoMotion].
|
|
||||||
* The MOV companion is excluded from import by default.
|
|
||||||
*/
|
*/
|
||||||
class IosConnector(
|
class IosConnector(
|
||||||
private val gphoto2Bin: String = "gphoto2",
|
private val python3Bin: String = "python3",
|
||||||
) : DeviceConnector {
|
) : DeviceConnector {
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger(IosConnector::class.java)
|
private val log = LoggerFactory.getLogger(IosConnector::class.java)
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
// gphoto2 date format in --list-files output
|
/** Path to the bundled helper script, extracted to a temp location on first use */
|
||||||
private val gphotoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
private val helperScript: String by lazy { extractHelperScript() }
|
||||||
|
|
||||||
override suspend fun detectDevices(): List<DeviceInfo> {
|
override suspend fun detectDevices(): List<DeviceInfo> {
|
||||||
// gphoto2 --auto-detect lists connected cameras/phones
|
return try {
|
||||||
val output = runGphoto2("--auto-detect")
|
val output = runHelper("list-devices")
|
||||||
/*
|
if (output.isBlank() || output.startsWith("Error")) return emptyList()
|
||||||
* Output format:
|
|
||||||
* Model Port
|
|
||||||
* ----------------------------------------------------------
|
|
||||||
* Apple iPhone usb:001,023
|
|
||||||
*/
|
|
||||||
val devices = mutableListOf<DeviceInfo>()
|
|
||||||
var parsingStarted = false
|
|
||||||
for (line in output.lines()) {
|
|
||||||
if (line.startsWith("---")) { parsingStarted = true; continue }
|
|
||||||
if (!parsingStarted || line.isBlank()) continue
|
|
||||||
|
|
||||||
// Split on 2+ spaces to separate model from port
|
val array = json.parseToJsonElement(output).jsonArray
|
||||||
val parts = line.trim().split(Regex("\\s{2,}"), limit = 2)
|
array.map { element ->
|
||||||
if (parts.size < 2) continue
|
val obj = element.jsonObject
|
||||||
val model = parts[0].trim()
|
DeviceInfo(
|
||||||
val port = parts[1].trim()
|
displayName = obj["name"]?.jsonPrimitive?.contentOrNull ?: "iPhone",
|
||||||
|
type = DeviceType.IOS,
|
||||||
if (model.contains("iPhone", ignoreCase = true) ||
|
gphotoPort = obj["udid"]?.jsonPrimitive?.contentOrNull,
|
||||||
model.contains("iPad", ignoreCase = true) ||
|
|
||||||
model.contains("Apple", ignoreCase = true)
|
|
||||||
) {
|
|
||||||
devices.add(
|
|
||||||
DeviceInfo(
|
|
||||||
displayName = model,
|
|
||||||
type = DeviceType.IOS,
|
|
||||||
gphotoPort = port,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("iOS device detection failed: ${e.message}")
|
||||||
|
emptyList()
|
||||||
}
|
}
|
||||||
return devices
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> {
|
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> {
|
||||||
val port = requirePort(device)
|
return try {
|
||||||
|
val output = runHelper("list-photos")
|
||||||
|
if (output.isBlank() || output.startsWith("Error")) return emptyList()
|
||||||
|
|
||||||
// gphoto2 --list-files gives index, name, size, date for each file in DCIM
|
val array = json.parseToJsonElement(output).jsonArray
|
||||||
val output = runGphoto2("--port", port, "--list-files")
|
val photos = array.mapNotNull { element ->
|
||||||
|
try {
|
||||||
|
val obj = element.jsonObject
|
||||||
|
val devicePath = obj["devicePath"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||||
|
val filename = obj["filename"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||||
|
val sizeBytes = obj["sizeBytes"]?.jsonPrimitive?.longOrNull ?: 0L
|
||||||
|
val dateIso = obj["dateIso"]?.jsonPrimitive?.contentOrNull
|
||||||
|
val sourceFolder = obj["sourceFolder"]?.jsonPrimitive?.contentOrNull ?: "DCIM"
|
||||||
|
val mediaTypeStr = obj["mediaType"]?.jsonPrimitive?.contentOrNull ?: "PHOTO"
|
||||||
|
|
||||||
/*
|
val dateTaken = if (dateIso != null) {
|
||||||
* Output format (each file):
|
try { Instant.parse(dateIso + "Z") } catch (_: Exception) {
|
||||||
* #1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01
|
try { LocalDateTime.parse(dateIso).atZone(ZoneId.systemDefault()).toInstant() }
|
||||||
* #2 IMG_0001.MOV 12344 KB 2026-07-15 14:22:01
|
catch (_: Exception) { Instant.EPOCH }
|
||||||
* #3 IMG_0002.JPG 3021 KB 2026-07-15 14:23:45
|
}
|
||||||
*/
|
} else Instant.EPOCH
|
||||||
val rawPhotos = mutableListOf<PhonePhoto>()
|
|
||||||
for (line in output.lines()) {
|
PhonePhoto(
|
||||||
if (!line.trimStart().startsWith("#")) continue
|
devicePath = devicePath,
|
||||||
val photo = parseGphoto2FileLine(line) ?: continue
|
filename = filename,
|
||||||
rawPhotos.add(photo)
|
sizeBytes = sizeBytes,
|
||||||
|
dateTaken = dateTaken,
|
||||||
|
exifMake = "Apple",
|
||||||
|
exifModel = device.displayName,
|
||||||
|
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
|
||||||
|
sourceFolder = sourceFolder,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.debug("Failed to parse photo entry: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag Live Photo pairs (HEIC + MOV with same base name)
|
||||||
|
tagLivePhotoPairs(photos).sortedByDescending { it.dateTaken }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("iOS photo listing failed: ${e.message}")
|
||||||
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawPhotos.isEmpty()) {
|
|
||||||
// gphoto2 connected but found no files. Common causes:
|
|
||||||
// 1. iPhone screen is locked — unlock and retry
|
|
||||||
// 2. Another USB device (e.g. Android phone in Charging mode) is blocking
|
|
||||||
// USB enumeration — unplug other devices or switch Android to File Transfer mode
|
|
||||||
// 3. PTPCamera daemon grabbed the device — run: killall PTPCamera
|
|
||||||
// We return empty and let the UI show the no-photos state with a hint.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect Live Photo pairs: HEIC + MOV with the same base name and close timestamps
|
|
||||||
return tagLivePhotoPairs(rawPhotos)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path {
|
override suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path {
|
||||||
val port = requirePort(device)
|
|
||||||
// gphoto2 uses 1-based file index; devicePath stores the index as "#N"
|
|
||||||
val fileIndex = photo.devicePath.removePrefix("#")
|
|
||||||
val destFile = destinationDir.resolve(photo.filename)
|
val destFile = destinationDir.resolve(photo.filename)
|
||||||
|
val output = runHelper("pull", photo.devicePath, destFile.absolutePathString())
|
||||||
// --get-file downloads to current dir; we set working dir to destinationDir
|
if (output.contains("Error") || !destFile.toFile().exists()) {
|
||||||
val output = runGphoto2InDir(
|
throw DeviceException("AFC pull failed for ${photo.filename}: $output")
|
||||||
destinationDir,
|
|
||||||
"--port", port,
|
|
||||||
"--get-file", fileIndex,
|
|
||||||
"--filename", photo.filename,
|
|
||||||
"--force-overwrite",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (output.contains("Error") && !output.contains("Saving file as")) {
|
|
||||||
throw DeviceException("gphoto2 pull failed for ${photo.filename}: $output")
|
|
||||||
}
|
}
|
||||||
log.info("Pulled ${photo.filename} → ${destFile.absolutePathString()}")
|
log.info("Pulled ${photo.filename} → ${destFile.absolutePathString()}")
|
||||||
return destFile
|
return destFile
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
|
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
|
||||||
val port = requirePort(device)
|
val output = runHelper("delete", photo.devicePath)
|
||||||
val fileIndex = photo.devicePath.removePrefix("#")
|
|
||||||
val output = runGphoto2("--port", port, "--delete-file", fileIndex)
|
|
||||||
if (output.contains("Error")) {
|
if (output.contains("Error")) {
|
||||||
throw DeviceException("Failed to delete ${photo.filename}: $output")
|
throw DeviceException("AFC delete failed for ${photo.filename}: $output")
|
||||||
}
|
}
|
||||||
log.info("Deleted ${photo.filename} from device")
|
log.info("Deleted ${photo.filename} from device")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** AFC doesn't provide remote checksums — verified by size + local SHA-256 in Copier */
|
||||||
* gphoto2 doesn't provide a remote hash command, so we return null.
|
|
||||||
* The Copier will verify by comparing the local copy against the pulled bytes.
|
|
||||||
*/
|
|
||||||
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
|
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Internal helpers
|
// Helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
private fun runHelper(vararg args: String): String {
|
||||||
* Parse a single line from `gphoto2 --list-files`.
|
val cmd = listOf(python3Bin, helperScript) + args.toList()
|
||||||
* Example: "#1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01"
|
log.debug("Running: ${cmd.joinToString(" ")}")
|
||||||
*/
|
|
||||||
private fun parseGphoto2FileLine(line: String): PhonePhoto? {
|
|
||||||
return try {
|
|
||||||
val trimmed = line.trim()
|
|
||||||
val indexEnd = trimmed.indexOf(' ')
|
|
||||||
val index = trimmed.substring(0, indexEnd).trimStart('#')
|
|
||||||
|
|
||||||
// Split remainder on multiple spaces
|
val process = ProcessBuilder(cmd)
|
||||||
val rest = trimmed.substring(indexEnd).trim().split(Regex("\\s{2,}"))
|
.redirectErrorStream(false)
|
||||||
if (rest.size < 3) return null
|
.start()
|
||||||
|
|
||||||
val filename = rest[0].trim()
|
val stdout = process.inputStream.bufferedReader().readText()
|
||||||
// rest[1] = "5906 KB" or "5906 kB"
|
val stderr = process.errorStream.bufferedReader().readText()
|
||||||
val sizeStr = rest[1].trim().split(" ").firstOrNull() ?: "0"
|
process.waitFor()
|
||||||
val sizeKb = sizeStr.toLongOrNull() ?: 0L
|
|
||||||
// rest[2] could be "2026-07-15 14:22:01" (split might be further)
|
|
||||||
val datePart = rest.drop(2).joinToString(" ").trim()
|
|
||||||
val dateTaken = try {
|
|
||||||
LocalDateTime.parse(datePart, gphotoDateFormatter)
|
|
||||||
.atZone(ZoneId.systemDefault()).toInstant()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Instant.EPOCH
|
|
||||||
}
|
|
||||||
|
|
||||||
val ext = filename.substringAfterLast(".", "").lowercase()
|
if (stderr.isNotBlank()) log.warn("iOS helper stderr: $stderr")
|
||||||
val mediaType = when (ext) {
|
return stdout.trim()
|
||||||
"mp4", "mov", "3gp", "avi" -> MediaType.VIDEO
|
|
||||||
"jpg", "jpeg", "heic", "heif", "png", "dng" -> MediaType.PHOTO
|
|
||||||
else -> MediaType.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
PhonePhoto(
|
|
||||||
devicePath = "#$index",
|
|
||||||
filename = filename,
|
|
||||||
sizeBytes = sizeKb * 1024,
|
|
||||||
dateTaken = dateTaken,
|
|
||||||
// iPhones always have camera Make/Model EXIF — we set it from device name
|
|
||||||
// since gphoto2 --list-files doesn't expose EXIF inline
|
|
||||||
exifMake = "Apple",
|
|
||||||
exifModel = null, // populated later if needed
|
|
||||||
mediaType = mediaType,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.debug("Could not parse gphoto2 line: $line — ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect Live Photo pairs by matching HEIC + MOV files with the same base name
|
* Extract the bundled helper script from the JAR resources to a temp file.
|
||||||
* (e.g. IMG_0001.HEIC + IMG_0001.MOV). Tags both sides appropriately.
|
* Returns the absolute path to the extracted script.
|
||||||
|
*/
|
||||||
|
private fun extractHelperScript(): String {
|
||||||
|
val resourcePath = "/scripts/photophetch-ios-helper.py"
|
||||||
|
val stream = IosConnector::class.java.getResourceAsStream(resourcePath)
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"Bundled iOS helper script not found in JAR. " +
|
||||||
|
"Expected resource: $resourcePath"
|
||||||
|
)
|
||||||
|
|
||||||
|
val tempFile = java.io.File.createTempFile("photophetch-ios-helper", ".py")
|
||||||
|
tempFile.deleteOnExit()
|
||||||
|
tempFile.outputStream().use { out -> stream.copyTo(out) }
|
||||||
|
tempFile.setExecutable(true)
|
||||||
|
|
||||||
|
log.info("Extracted iOS helper script to ${tempFile.absolutePath}")
|
||||||
|
return tempFile.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect Live Photo pairs by matching HEIC/HEIF + MOV files with the same base name.
|
||||||
*/
|
*/
|
||||||
private fun tagLivePhotoPairs(photos: List<PhonePhoto>): List<PhonePhoto> {
|
private fun tagLivePhotoPairs(photos: List<PhonePhoto>): List<PhonePhoto> {
|
||||||
// Build a map from base name (no extension) → list of photos
|
|
||||||
val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
|
val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
|
||||||
|
|
||||||
return photos.map { photo ->
|
return photos.map { photo ->
|
||||||
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
|
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
|
||||||
val siblings = byBaseName[baseName] ?: return@map photo
|
val siblings = byBaseName[baseName] ?: return@map photo
|
||||||
|
val hasHeic = siblings.any { it.filename.endsWith(".HEIC", ignoreCase = true) || it.filename.endsWith(".HEIF", ignoreCase = true) }
|
||||||
val hasHeic = siblings.any {
|
val hasMov = siblings.any { it.filename.endsWith(".MOV", ignoreCase = true) }
|
||||||
it.filename.endsWith(".HEIC", ignoreCase = true) ||
|
|
||||||
it.filename.endsWith(".HEIF", ignoreCase = true)
|
|
||||||
}
|
|
||||||
val hasMov = siblings.any {
|
|
||||||
it.filename.endsWith(".MOV", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasHeic || !hasMov) return@map photo
|
if (!hasHeic || !hasMov) return@map photo
|
||||||
|
|
||||||
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
||||||
when (ext) {
|
when (ext) {
|
||||||
"heic", "heif" -> photo.copy(isLivePhotoStill = true)
|
"heic", "heif" -> photo.copy(isLivePhotoStill = true)
|
||||||
@@ -230,26 +192,4 @@ class IosConnector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requirePort(device: DeviceInfo): String =
|
|
||||||
device.gphotoPort ?: throw DeviceException("No gphoto2 port for device: ${device.displayName}")
|
|
||||||
|
|
||||||
private fun runGphoto2(vararg args: String): String = runGphoto2InDir(null, *args)
|
|
||||||
|
|
||||||
private fun runGphoto2InDir(workingDir: Path?, vararg args: String): String {
|
|
||||||
val cmd = buildList {
|
|
||||||
add(gphoto2Bin)
|
|
||||||
addAll(args)
|
|
||||||
}
|
|
||||||
log.debug("Running: ${cmd.joinToString(" ")}")
|
|
||||||
|
|
||||||
val builder = ProcessBuilder(cmd).redirectErrorStream(true)
|
|
||||||
if (workingDir != null) builder.directory(workingDir.toFile())
|
|
||||||
|
|
||||||
val process = builder.start()
|
|
||||||
val output = process.inputStream.bufferedReader().readText()
|
|
||||||
process.waitFor()
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ data class AppConfig(
|
|||||||
val heicWarningDismissed: Boolean = false,
|
val heicWarningDismissed: Boolean = false,
|
||||||
/** Path to adb binary, or null to use PATH */
|
/** Path to adb binary, or null to use PATH */
|
||||||
val adbPath: String? = null,
|
val adbPath: String? = null,
|
||||||
/** Path to gphoto2 binary, or null to use PATH */
|
/** Path to python3 binary, or null to use PATH (needed for iOS via pymobiledevice3) */
|
||||||
val gphoto2Path: String? = null,
|
val python3Path: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
try {
|
try {
|
||||||
val devices = withContext(Dispatchers.IO) {
|
val devices = withContext(Dispatchers.IO) {
|
||||||
val android = AndroidConnector(_config.value.adbPath ?: "adb")
|
val android = AndroidConnector(_config.value.adbPath ?: "adb")
|
||||||
val ios = IosConnector(_config.value.gphoto2Path ?: "gphoto2")
|
val ios = IosConnector(_config.value.python3Path ?: "python3")
|
||||||
android.detectDevices() + ios.detectDevices()
|
android.detectDevices() + ios.detectDevices()
|
||||||
}
|
}
|
||||||
_deviceScan.value = DeviceScanState.Found(devices)
|
_deviceScan.value = DeviceScanState.Found(devices)
|
||||||
@@ -345,6 +345,6 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
private fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
|
private fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
|
||||||
DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
|
DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
|
||||||
DeviceType.IOS -> IosConnector(_config.value.gphoto2Path ?: "gphoto2")
|
DeviceType.IOS -> IosConnector(_config.value.python3Path ?: "python3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
src/main/resources/scripts/photophetch-ios-helper.py
Normal file
160
src/main/resources/scripts/photophetch-ios-helper.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
photophetch-ios-helper.py
|
||||||
|
|
||||||
|
Thin wrapper around pymobiledevice3 AFC for PhotoPhetch iOS connectivity.
|
||||||
|
Called by IosConnector as a subprocess.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 photophetch-ios-helper.py list-photos
|
||||||
|
python3 photophetch-ios-helper.py pull /DCIM/148APPLE/IMG_001.HEIC /tmp/dest.heic
|
||||||
|
python3 photophetch-ios-helper.py list-devices
|
||||||
|
|
||||||
|
Output is JSON on stdout. Errors go to stderr with exit code 1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
MEDIA_EXTENSIONS = {
|
||||||
|
'jpg', 'jpeg', 'heic', 'heif', 'avif', 'webp', 'png',
|
||||||
|
'dng', 'raw', 'arw', 'cr2', 'cr3', 'nef', 'orf', 'rw2',
|
||||||
|
'tiff', 'tif', 'bmp',
|
||||||
|
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
|
||||||
|
}
|
||||||
|
|
||||||
|
VIDEO_EXTENSIONS = {
|
||||||
|
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_afc_service():
|
||||||
|
from pymobiledevice3.lockdown import create_using_usbmux
|
||||||
|
from pymobiledevice3.services.afc import AfcService
|
||||||
|
lockdown = create_using_usbmux()
|
||||||
|
return AfcService(lockdown), lockdown
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_devices():
|
||||||
|
try:
|
||||||
|
from pymobiledevice3.usbmux import select_devices_by_connection_type
|
||||||
|
devices = select_devices_by_connection_type(connection_type='USB')
|
||||||
|
result = [{'udid': d.serial, 'name': getattr(d, 'label', d.serial)} for d in devices]
|
||||||
|
print(json.dumps(result))
|
||||||
|
except Exception as e:
|
||||||
|
# fallback: try lockdown
|
||||||
|
try:
|
||||||
|
from pymobiledevice3.lockdown import create_using_usbmux
|
||||||
|
lockdown = create_using_usbmux()
|
||||||
|
print(json.dumps([{
|
||||||
|
'udid': lockdown.udid,
|
||||||
|
'name': lockdown.display_name or lockdown.udid,
|
||||||
|
}]))
|
||||||
|
except Exception as e2:
|
||||||
|
print(json.dumps([]))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_photos():
|
||||||
|
try:
|
||||||
|
afc, _ = get_afc_service()
|
||||||
|
|
||||||
|
# List all DCIM subdirectories
|
||||||
|
try:
|
||||||
|
dcim_entries = afc.listdir('/DCIM')
|
||||||
|
except Exception:
|
||||||
|
print(json.dumps([]))
|
||||||
|
return
|
||||||
|
|
||||||
|
photos = []
|
||||||
|
current_year = datetime.now().year
|
||||||
|
|
||||||
|
for folder in sorted(dcim_entries):
|
||||||
|
folder_path = f'/DCIM/{folder}'
|
||||||
|
try:
|
||||||
|
entries = afc.listdir(folder_path)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
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}'
|
||||||
|
|
||||||
|
# Get file info
|
||||||
|
try:
|
||||||
|
info = afc.stat(device_path)
|
||||||
|
size_bytes = int(info.get('st_size', 0))
|
||||||
|
# st_mtime is nanoseconds since epoch on AFC
|
||||||
|
mtime_ns = int(info.get('st_mtime', 0))
|
||||||
|
mtime_s = mtime_ns / 1_000_000_000 if mtime_ns > 1_000_000_000 else mtime_ns
|
||||||
|
date_iso = datetime.fromtimestamp(mtime_s).isoformat() if mtime_s > 0 else None
|
||||||
|
except Exception:
|
||||||
|
size_bytes = 0
|
||||||
|
date_iso = None
|
||||||
|
|
||||||
|
photos.append({
|
||||||
|
'devicePath': device_path,
|
||||||
|
'filename': filename,
|
||||||
|
'sizeBytes': size_bytes,
|
||||||
|
'dateIso': date_iso,
|
||||||
|
'sourceFolder': folder,
|
||||||
|
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
|
||||||
|
'isHeic': ext in ('heic', 'heif'),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(json.dumps(photos))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_pull(device_path: str, local_path: str):
|
||||||
|
try:
|
||||||
|
afc, _ = get_afc_service()
|
||||||
|
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||||
|
with afc.open(device_path, 'rb') as remote_f:
|
||||||
|
with open(local_path, 'wb') as local_f:
|
||||||
|
while True:
|
||||||
|
chunk = remote_f.read(64 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
local_f.write(chunk)
|
||||||
|
print(json.dumps({'ok': True, 'path': local_path}))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_delete(device_path: str):
|
||||||
|
try:
|
||||||
|
afc, _ = get_afc_service()
|
||||||
|
afc.rm(device_path)
|
||||||
|
print(json.dumps({'ok': True}))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print('Usage: photophetch-ios-helper.py <command> [args]', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
command = sys.argv[1]
|
||||||
|
|
||||||
|
if command == 'list-devices':
|
||||||
|
cmd_list_devices()
|
||||||
|
elif command == 'list-photos':
|
||||||
|
cmd_list_photos()
|
||||||
|
elif command == 'pull' and len(sys.argv) == 4:
|
||||||
|
cmd_pull(sys.argv[2], sys.argv[3])
|
||||||
|
elif command == 'delete' and len(sys.argv) == 3:
|
||||||
|
cmd_delete(sys.argv[2])
|
||||||
|
else:
|
||||||
|
print(f'Unknown command: {command}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Reference in New Issue
Block a user