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:
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