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.MediaType
|
||||
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 java.nio.file.Path
|
||||
import java.time.Instant
|
||||
@@ -13,215 +23,167 @@ import java.time.format.DateTimeFormatter
|
||||
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]).
|
||||
* Install via: brew install gphoto2
|
||||
* Requires pymobiledevice3 installed via pipx:
|
||||
* brew install pipx && pipx install pymobiledevice3
|
||||
*
|
||||
* Important macOS note: The built-in Image Capture daemon (PTPCamera) will
|
||||
* conflict with gphoto2. If listing returns no files, run:
|
||||
* killall PTPCamera
|
||||
* PhotoPhetch shows instructions for this when no photos are found on a
|
||||
* connected iPhone.
|
||||
* Uses a bundled Python helper script (photophetch-ios-helper.py) that
|
||||
* calls the pymobiledevice3 AFC service to list and pull files.
|
||||
*
|
||||
* Live Photo handling: gphoto2 lists both the .HEIC and the companion .MOV
|
||||
* with matching base names. We detect the pair by comparing filenames and
|
||||
* flag [PhonePhoto.isLivePhotoStill] / [PhonePhoto.isLivePhotoMotion].
|
||||
* The MOV companion is excluded from import by default.
|
||||
* Replaces the previous gphoto2-based implementation which stopped working
|
||||
* on iOS 17+ due to Apple tightening PTP access.
|
||||
*/
|
||||
class IosConnector(
|
||||
private val gphoto2Bin: String = "gphoto2",
|
||||
private val python3Bin: String = "python3",
|
||||
) : DeviceConnector {
|
||||
|
||||
private val log = LoggerFactory.getLogger(IosConnector::class.java)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// gphoto2 date format in --list-files output
|
||||
private val gphotoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
/** Path to the bundled helper script, extracted to a temp location on first use */
|
||||
private val helperScript: String by lazy { extractHelperScript() }
|
||||
|
||||
override suspend fun detectDevices(): List<DeviceInfo> {
|
||||
// gphoto2 --auto-detect lists connected cameras/phones
|
||||
val output = runGphoto2("--auto-detect")
|
||||
/*
|
||||
* 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
|
||||
return try {
|
||||
val output = runHelper("list-devices")
|
||||
if (output.isBlank() || output.startsWith("Error")) return emptyList()
|
||||
|
||||
// Split on 2+ spaces to separate model from port
|
||||
val parts = line.trim().split(Regex("\\s{2,}"), limit = 2)
|
||||
if (parts.size < 2) continue
|
||||
val model = parts[0].trim()
|
||||
val port = parts[1].trim()
|
||||
|
||||
if (model.contains("iPhone", ignoreCase = true) ||
|
||||
model.contains("iPad", ignoreCase = true) ||
|
||||
model.contains("Apple", ignoreCase = true)
|
||||
) {
|
||||
devices.add(
|
||||
val array = json.parseToJsonElement(output).jsonArray
|
||||
array.map { element ->
|
||||
val obj = element.jsonObject
|
||||
DeviceInfo(
|
||||
displayName = model,
|
||||
displayName = obj["name"]?.jsonPrimitive?.contentOrNull ?: "iPhone",
|
||||
type = DeviceType.IOS,
|
||||
gphotoPort = port,
|
||||
)
|
||||
gphotoPort = obj["udid"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.warn("iOS device detection failed: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
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 output = runGphoto2("--port", port, "--list-files")
|
||||
val array = json.parseToJsonElement(output).jsonArray
|
||||
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"
|
||||
|
||||
/*
|
||||
* Output format (each file):
|
||||
* #1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01
|
||||
* #2 IMG_0001.MOV 12344 KB 2026-07-15 14:22:01
|
||||
* #3 IMG_0002.JPG 3021 KB 2026-07-15 14:23:45
|
||||
*/
|
||||
val rawPhotos = mutableListOf<PhonePhoto>()
|
||||
for (line in output.lines()) {
|
||||
if (!line.trimStart().startsWith("#")) continue
|
||||
val photo = parseGphoto2FileLine(line) ?: continue
|
||||
rawPhotos.add(photo)
|
||||
val dateTaken = if (dateIso != null) {
|
||||
try { Instant.parse(dateIso + "Z") } catch (_: Exception) {
|
||||
try { LocalDateTime.parse(dateIso).atZone(ZoneId.systemDefault()).toInstant() }
|
||||
catch (_: Exception) { Instant.EPOCH }
|
||||
}
|
||||
} else Instant.EPOCH
|
||||
|
||||
PhonePhoto(
|
||||
devicePath = devicePath,
|
||||
filename = filename,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
|
||||
// --get-file downloads to current dir; we set working dir to destinationDir
|
||||
val output = runGphoto2InDir(
|
||||
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")
|
||||
val output = runHelper("pull", photo.devicePath, destFile.absolutePathString())
|
||||
if (output.contains("Error") || !destFile.toFile().exists()) {
|
||||
throw DeviceException("AFC pull failed for ${photo.filename}: $output")
|
||||
}
|
||||
log.info("Pulled ${photo.filename} → ${destFile.absolutePathString()}")
|
||||
return destFile
|
||||
}
|
||||
|
||||
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
|
||||
val port = requirePort(device)
|
||||
val fileIndex = photo.devicePath.removePrefix("#")
|
||||
val output = runGphoto2("--port", port, "--delete-file", fileIndex)
|
||||
val output = runHelper("delete", photo.devicePath)
|
||||
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")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** AFC doesn't provide remote checksums — verified by size + local SHA-256 in Copier */
|
||||
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun runHelper(vararg args: String): String {
|
||||
val cmd = listOf(python3Bin, helperScript) + args.toList()
|
||||
log.debug("Running: ${cmd.joinToString(" ")}")
|
||||
|
||||
val process = ProcessBuilder(cmd)
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
|
||||
val stdout = process.inputStream.bufferedReader().readText()
|
||||
val stderr = process.errorStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
|
||||
if (stderr.isNotBlank()) log.warn("iOS helper stderr: $stderr")
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single line from `gphoto2 --list-files`.
|
||||
* Example: "#1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01"
|
||||
* Extract the bundled helper script from the JAR resources to a temp file.
|
||||
* Returns the absolute path to the extracted script.
|
||||
*/
|
||||
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 rest = trimmed.substring(indexEnd).trim().split(Regex("\\s{2,}"))
|
||||
if (rest.size < 3) return null
|
||||
|
||||
val filename = rest[0].trim()
|
||||
// rest[1] = "5906 KB" or "5906 kB"
|
||||
val sizeStr = rest[1].trim().split(" ").firstOrNull() ?: "0"
|
||||
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()
|
||||
val mediaType = when (ext) {
|
||||
"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,
|
||||
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"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.debug("Could not parse gphoto2 line: $line — ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
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 + MOV files with the same base name
|
||||
* (e.g. IMG_0001.HEIC + IMG_0001.MOV). Tags both sides appropriately.
|
||||
* Detect Live Photo pairs by matching HEIC/HEIF + MOV files with the same base name.
|
||||
*/
|
||||
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) }
|
||||
|
||||
return photos.map { photo ->
|
||||
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
|
||||
val siblings = byBaseName[baseName] ?: return@map photo
|
||||
|
||||
val hasHeic = siblings.any {
|
||||
it.filename.endsWith(".HEIC", ignoreCase = true) ||
|
||||
it.filename.endsWith(".HEIF", ignoreCase = true)
|
||||
}
|
||||
val hasMov = siblings.any {
|
||||
it.filename.endsWith(".MOV", ignoreCase = true)
|
||||
}
|
||||
|
||||
val hasHeic = siblings.any { 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
|
||||
|
||||
val ext = photo.filename.substringAfterLast(".", "").lowercase()
|
||||
when (ext) {
|
||||
"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,
|
||||
/** Path to adb binary, or null to use PATH */
|
||||
val adbPath: String? = null,
|
||||
/** Path to gphoto2 binary, or null to use PATH */
|
||||
val gphoto2Path: String? = null,
|
||||
/** Path to python3 binary, or null to use PATH (needed for iOS via pymobiledevice3) */
|
||||
val python3Path: String? = null,
|
||||
)
|
||||
|
||||
@@ -100,7 +100,7 @@ class AppState(private val configStore: ConfigStore) {
|
||||
try {
|
||||
val devices = withContext(Dispatchers.IO) {
|
||||
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()
|
||||
}
|
||||
_deviceScan.value = DeviceScanState.Found(devices)
|
||||
@@ -345,6 +345,6 @@ class AppState(private val configStore: ConfigStore) {
|
||||
// -------------------------------------------------------------------------
|
||||
private fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
|
||||
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