- Face/person tags from iPhone not preserved (Photos.app specific)
Resources
16Install
npx skillscat add dannyleb/iphone-media-migration Install via the SkillsCat registry.
iPhone Media Migration Tool - Claude Code Skill
Purpose
Build a macOS GUI application that connects directly to an iPhone via USB, displays the photo library structure with thumbnails (matching iPhone's Photos app organization), allows users to select specific albums or photos, and transfers them to a user-selected destination while converting to standard formats.
Core Requirements
Media Access Strategy
Direct iPhone Connection (Primary Method)
- USB connection via libimobiledevice/pymobiledevice3
- Access iPhone photo library without iTunes or Photos.app
- Read album structure, metadata, and media files directly from device
- No dependency on macOS Photos.app
iOS Photo Library Protocol
- Use AFC (Apple File Connection) or Photo Library services
- Access via
/DCIM/or Photo Library database on device - Extract album metadata, photo metadata, thumbnails
- Support locked/unlocked device states (requires device trust)
Album Structure Access
iPhone Photo Library Structure:
- All Photos (sorted by date/time)
- User Albums (custom created albums)
- Favorites
- Screenshots
- Selfies
- Live Photos
- Videos
- Recently Deleted
- Hidden
Data Access via pymobiledevice3:
# Device connection
from pymobiledevice3.lockdown import LockdownClient
from pymobiledevice3.services.afc import AfcService
from pymobiledevice3.services.mobile_image_mounter import MobileImageMounter
# Connect to device
lockdown = LockdownClient()
device_info = lockdown.all_values
# Access photo library
# Photos stored in /DCIM/ folder structure
# Album data in PhotoData/AlbumData.sqlite or similar
# Get album list with photo counts
albums = get_iphone_albums(lockdown)
# Returns: {"My Trip": 45, "Favorites": 23, "Screenshots": 12, ...}Thumbnail Extraction:
- iPhones store thumbnails in
.THMfiles or embedded metadata - Generate thumbnails on-the-fly from HEIC/JPEG for preview
- Thumbnail size: 200x200px for grid view
- Lazy loading for performance (large libraries)
GUI Organization Views
Library View (Main Panel):
┌─────────────────────────────────────────┐
│ Device: Danny's iPhone [Status] │
├─────────────────────────────────────────┤
│ Albums: Thumbnails: │
│ ☑ All Photos (1,234) [grid view] │
│ ☐ My Trip (45) [thumbnails] │
│ ☑ Favorites (23) [shown for] │
│ ☐ Screenshots (12) [selected] │
│ ☐ Family (89) [albums] │
│ │
│ [Select Destination] [Transfer] │
└─────────────────────────────────────────┘Format Conversion
HEIC → JPEG:
- Library:
pillow-heiforpyheif - Quality: 95-100 (highest quality)
- Preserve EXIF metadata
- Command:
convert_heic(input_path, output_path, quality=95)
Live Photos:
- Extract both HEIC and MOV components
- Naming:
photo_name.jpg+photo_name_live.mov - Link them via sidecar file or naming convention
Videos:
- MOV files are already standard format
- Option to transcode to H.264/H.265 using ffmpeg if needed
- Preserve original by default
Voice Memos:
- Location: Separate from Photos library
- Format: M4A (already standard)
- Metadata: recording date, title
Folder Structure Output
User selects destination via GUI folder picker. Structure created matches iPhone organization:
[User Selected Path]/
├── All Photos/
│ ├── 2024-03-15_14-30-22.jpg
│ └── 2024-03-16_09-15-33.jpg
├── Favorites/
│ ├── IMG_001.jpg
│ └── IMG_002.jpg
├── My Trip/ (user's custom album name preserved)
│ ├── photo1.jpg
│ └── video1.mp4
├── Screenshots/
├── Voice Memos/
│ └── Recording_001.m4a
└── _migration_manifest.jsonNaming Convention:
- Preserve original iPhone filenames where possible
- For "All Photos" (no album), use date-time format:
YYYY-MM-DD_HH-MM-SS.jpg - Custom album names used as folder names exactly as on iPhone
- Special characters in album names sanitized for filesystem compatibility
Technical Implementation
Dependencies
# iPhone device access
import pymobiledevice3
from pymobiledevice3.lockdown import LockdownClient
from pymobiledevice3.services.afc import AfcService
# GUI framework (choose one)
import tkinter as tk # Built-in, basic
# OR
from PyQt6.QtWidgets import * # Better looking, more features
from PyQt6.QtCore import *
from PyQt6.QtGui import *
# Image processing
from PIL import Image
import pillow_heif # HEIC support
# Core
from pathlib import Path
import json
from datetime import datetimeApplication Architecture
1. Device Manager
- Detect iPhone connection via USB
- Establish lockdown session (device trust required)
- Monitor connection state (connected/disconnected)
- Display device info (name, model, iOS version)
2. Photo Library Reader
- Access iPhone photo database
- Parse album structure
- Extract photo metadata (date, location, format)
- Generate/fetch thumbnails for preview
3. GUI Application (Main Window)
Components:
- Device Status Bar - Shows connected iPhone name
- Album List Panel (left side) - Checkboxes for each album with photo count
- Thumbnail Grid (right side) - Preview of selected album contents
- Destination Selector - Button to choose save location
- Transfer Controls - Progress bar, transfer/cancel buttons
- Settings Panel - Quality, format conversion options
4. Transfer Manager
- Queue selected albums/photos
- Convert HEIC → JPEG, MOV → MP4 as needed
- Copy to user-selected destination
- Update progress bar
- Generate completion manifest
Key Classes
class iPhoneDeviceManager:
"""Manages iPhone USB connection"""
def __init__(self):
self.device = None
self.lockdown = None
def detect_device(self) -> bool:
"""Returns True if iPhone connected"""
pass
def connect(self):
"""Establish lockdown session"""
self.lockdown = LockdownClient()
def get_device_info(self) -> dict:
"""Returns device name, model, iOS version"""
pass
def is_connected(self) -> bool:
"""Check if device still connected"""
pass
class PhotoLibraryReader:
"""Reads photo library from connected iPhone"""
def __init__(self, lockdown_client):
self.lockdown = lockdown_client
self.afc = AfcService(lockdown_client)
def get_albums(self) -> dict:
"""
Returns:
{
"All Photos": {"count": 1234, "path": "/DCIM/"},
"My Trip": {"count": 45, "path": "..."},
"Favorites": {"count": 23, "path": "..."}
}
"""
pass
def get_photos_in_album(self, album_name: str) -> list:
"""Returns list of photo metadata dicts"""
pass
def get_thumbnail(self, photo_path: str) -> Image:
"""Generate/fetch thumbnail for preview"""
pass
def download_photo(self, photo_path: str, dest: Path):
"""Download full-res photo from device"""
pass
class MainWindow(QMainWindow): # or tk.Tk() for tkinter
"""Main GUI window"""
def __init__(self):
super().__init__()
self.device_manager = iPhoneDeviceManager()
self.library_reader = None
self.selected_albums = set()
self.destination_path = None
self.init_ui()
self.check_device_connection()
def init_ui(self):
"""Setup GUI layout"""
# Status bar
self.status_label = QLabel("No device connected")
# Album list with checkboxes
self.album_list = QListWidget()
self.album_list.itemChanged.connect(self.on_album_selected)
# Thumbnail grid
self.thumbnail_grid = QGridLayout()
# Destination selector
self.dest_button = QPushButton("Select Destination")
self.dest_button.clicked.connect(self.select_destination)
# Transfer button
self.transfer_button = QPushButton("Transfer Selected")
self.transfer_button.clicked.connect(self.start_transfer)
self.transfer_button.setEnabled(False)
# Progress bar
self.progress_bar = QProgressBar()
def check_device_connection(self):
"""Poll for iPhone connection"""
if self.device_manager.detect_device():
self.device_manager.connect()
self.on_device_connected()
else:
QTimer.singleShot(2000, self.check_device_connection)
def on_device_connected(self):
"""Handle device connection"""
device_info = self.device_manager.get_device_info()
self.status_label.setText(f"Connected: {device_info['name']}")
# Initialize library reader
self.library_reader = PhotoLibraryReader(self.device_manager.lockdown)
# Load albums
self.load_albums()
def load_albums(self):
"""Populate album list"""
albums = self.library_reader.get_albums()
for album_name, album_info in albums.items():
item = QListWidgetItem(f"{album_name} ({album_info['count']})")
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(Qt.CheckState.Unchecked)
item.setData(Qt.ItemDataRole.UserRole, album_name)
self.album_list.addItem(item)
def on_album_selected(self, item):
"""Handle album checkbox toggle"""
album_name = item.data(Qt.ItemDataRole.UserRole)
if item.checkState() == Qt.CheckState.Checked:
self.selected_albums.add(album_name)
self.show_thumbnails(album_name)
else:
self.selected_albums.discard(album_name)
# Enable transfer if destination selected and albums checked
self.transfer_button.setEnabled(
len(self.selected_albums) > 0 and
self.destination_path is not None
)
def show_thumbnails(self, album_name: str):
"""Display thumbnails for selected album"""
photos = self.library_reader.get_photos_in_album(album_name)
# Clear existing thumbnails
# ... clear grid layout ...
# Load thumbnails (lazy loading for large albums)
for i, photo in enumerate(photos[:100]): # Limit to first 100
thumbnail = self.library_reader.get_thumbnail(photo['path'])
# Add to grid...
def select_destination(self):
"""Open folder picker dialog"""
folder = QFileDialog.getExistingDirectory(
self,
"Select Destination Folder",
str(Path.home())
)
if folder:
self.destination_path = Path(folder)
self.dest_button.setText(f"Destination: {folder}")
# Enable transfer if albums selected
self.transfer_button.setEnabled(len(self.selected_albums) > 0)
def start_transfer(self):
"""Begin transferring selected albums"""
self.transfer_button.setEnabled(False)
self.progress_bar.setValue(0)
# Create transfer thread to avoid blocking UI
self.transfer_thread = TransferThread(
self.library_reader,
self.selected_albums,
self.destination_path
)
self.transfer_thread.progress_updated.connect(self.on_transfer_progress)
self.transfer_thread.finished.connect(self.on_transfer_complete)
self.transfer_thread.start()
def on_transfer_progress(self, percent):
"""Update progress bar"""
self.progress_bar.setValue(percent)
def on_transfer_complete(self):
"""Handle transfer completion"""
QMessageBox.information(self, "Complete", "Transfer finished!")
self.transfer_button.setEnabled(True)
class TransferThread(QThread):
"""Background thread for photo transfer"""
progress_updated = pyqtSignal(int)
def __init__(self, library_reader, selected_albums, destination):
super().__init__()
self.library_reader = library_reader
self.selected_albums = selected_albums
self.destination = destination
def run(self):
"""Execute transfer in background"""
converter = MediaConverter()
total_photos = 0
processed = 0
# Count total photos
for album in self.selected_albums:
photos = self.library_reader.get_photos_in_album(album)
total_photos += len(photos)
# Transfer each album
for album in self.selected_albums:
album_folder = self.destination / album
album_folder.mkdir(exist_ok=True)
photos = self.library_reader.get_photos_in_album(album)
for photo in photos:
# Download from device
temp_path = Path(f"/tmp/{photo['filename']}")
self.library_reader.download_photo(photo['path'], temp_path)
# Convert if needed
if temp_path.suffix.lower() in ['.heic', '.heif']:
output_path = converter.convert_heic(temp_path)
final_path = album_folder / output_path.name
else:
final_path = album_folder / temp_path.name
shutil.copy2(temp_path, final_path)
processed += 1
progress = int((processed / total_photos) * 100)
self.progress_updated.emit(progress)
# Generate manifest
self.create_manifest()
class MediaConverter:
"""Same as before - HEIC to JPEG conversion"""
pass # (Keep existing implementation)Edge Cases & Considerations
Device Connection
- Device not trusted - Prompt user to unlock iPhone and tap "Trust"
- Device locked during transfer - Pause transfer, prompt to unlock
- Device disconnected mid-transfer - Save progress, allow resume
- Multiple iPhones connected - Let user select which device
Photo Selection
- Large albums - Lazy load thumbnails (don't load all 10k at once)
- Mixed selection - User selects some albums fully, some photos individually
- Deselect all - Quick "uncheck all" button
- Preview before transfer - Show total size, estimated time
Duplicates
- Photos can exist in multiple albums on iPhone
- Strategy: Copy to each selected album folder (user explicitly chose both)
- Don't auto-deduplicate unless user opts in
Missing/Inaccessible Photos
- iCloud Photos not downloaded - Detect and warn before transfer
- Recently Deleted - Exclude by default, warn if user selects
- Hidden album - Require explicit opt-in
Date Preservation
- Maintain original creation date from EXIF
- Use
os.utime()to set file timestamps - Fall back to iPhone metadata if EXIF missing
Format Conversion
- Live Photos - Detect paired HEIC+MOV, export both with linked naming
- Bursts - Option to expand all or keep representative photo
- Videos - Default to no transcoding (MOV already compatible)
- Corrupted files - Skip with error log, continue transfer
Performance
- Large libraries (10k+ photos) need:
- Background threading (don't freeze UI)
- Lazy thumbnail loading
- Batch transfers with progress updates
- Memory management (clear thumbnails of unselected albums)
Storage
- Pre-flight space check before transfer
- Warn if destination has insufficient space
- Show estimated size for selected albums
Error Handling
Device Errors:
- Device not found → Display "Connect iPhone" message
- Trust dialog not accepted → Show instructions with screenshot
- Connection lost → Pause transfer, show reconnect dialog
Transfer Errors:
- Insufficient space → Stop before starting, prompt for new destination
- Permission denied → Request appropriate macOS permissions
- Corrupted photo → Skip, log error, continue with rest
- Write failure → Retry 3 times, then skip
GUI Errors:
- Thumbnail generation fails → Show placeholder icon
- Album list empty → Display "No albums found" message
- Slow device response → Show "Loading..." spinner
macOS-Specific Requirements
Permissions
- USB Access - Handled by pymobiledevice3
- Device Trust - User must unlock iPhone and tap "Trust This Computer"
- File System Access - No special permissions needed (writing to user-selected folder)
Device Detection
- Monitor for USB device events
- Use IOKit notifications or poll for device presence
- Handle sleep/wake cycles (device may disconnect)
GUI Framework Choice
tkinter:
- ✓ Built into Python
- ✓ No dependencies
- ✗ Basic appearance
- ✗ Limited thumbnail grid capabilities
PyQt6/PySide6:
- ✓ Native macOS look
- ✓ Powerful grid views, async loading
- ✓ Better performance for large albums
- ✗ Requires installation
- ✗ Slightly more complex
Recommendation: PyQt6 for better UX with large photo libraries
Packaging
- Use
py2appto create standalone .app bundle - Include all dependencies
- Code sign for Gatekeeper approval
- DMG installer for easy distribution
Testing Strategy
Phase 1: Device Connection
- Test with iPhone connected, unlocked, trusted
- Test with iPhone locked (should prompt to unlock)
- Test with device not trusted (show trust dialog instructions)
- Test device disconnect/reconnect during idle
- Test with no device connected (show waiting state)
Phase 2: Album Loading
- Test with small library (< 100 photos)
- Test with medium library (1,000 photos)
- Test with large library (10,000+ photos)
- Verify all album types load (user albums, favorites, screenshots)
- Check album photo counts match iPhone
Phase 3: Thumbnail Display
- Verify thumbnails load for selected album
- Test lazy loading (don't load all at once)
- Test thumbnail quality (should be recognizable)
- Verify grid scrolling performance
Phase 4: Selection & Transfer
- Select single album → transfer → verify structure
- Select multiple albums → verify all transferred
- Select "All Photos" → verify date-based organization
- Verify HEIC → JPEG conversion quality
- Verify original filenames preserved
- Check metadata (dates, EXIF) preserved
Phase 5: Edge Cases
- Transfer with < 10MB free space (should fail with warning)
- Disconnect device mid-transfer (should pause)
- Select 10,000+ photos (test progress bar, memory usage)
- Albums with special characters in names
- Live Photos (verify both HEIC and MOV transferred)
GUI Testing
- Window resize behavior
- Checkbox interactions
- Progress bar updates smoothly
- Folder picker works
- Cancel button interrupts transfer
- Application doesn't freeze during long transfers
Future Enhancements
- Incremental sync - Remember what's been transferred, only add new photos
- Two-way sync - Detect changes on both sides
- Cloud backup integration - Auto-upload to Google Drive, Dropbox, etc.
- Photo deduplication - Detect and skip identical photos across albums
- Individual photo selection - Not just album-level, but photo-level checkboxes
- Filters - Transfer only photos from date range, only videos, etc.
- Batch editing - Rotate, resize before transfer
- Preview before transfer - Full-screen photo viewer
- WiFi transfer - Connect over local network instead of USB
- Multiple device support - Transfer from multiple iPhones simultaneously
- Scheduled transfers - Auto-transfer when iPhone connected
- Smart albums - Recreate iPhone smart albums via tags/metadata
macOS Application Notes
Minimum Requirements:
- macOS 11.0+ (Big Sur)
- Python 3.9+
- USB port for iPhone connection
- 500MB free space for app + dependencies
Device Compatibility:
- iPhone 6s and later
- iOS 13.0 and later
- iPad support (same codebase)
Installation:
- Drag .app to Applications folder
- First run: Grant accessibility permissions if prompted
- Trust iPhone when dialog appears
Known Limitations:
- Requires wired USB connection (WiFi sync not yet supported)
- Cannot transfer iCloud-only photos not downloaded to device
- Face/person tags from iPhone not preserved (Photos.app specific)