Recover iPhone Photos with Claude Code
Build a safe local tool to recover, audit, and browse iPhone backup media.
Introduction
30 Second Summary
Old iPhone backups can sit unnoticed for years. Their hash-filled folders can hide photos and videos you thought were lost.
In this project, you will use Claude Code to build a Python script that safely recovers camera-roll media from an unencrypted local iPhone backup. The recovered collection appears as a year-by-year gallery backed by a CSV audit report.
What You'll Build
The finished local gallery turns your recovered collection into a chronological view you can browse from your computer.
By the end of this project, you'll have:
- Used a copied index from Manifest.db to reveal camera-roll paths while the original backup stays unchanged.
- Recovered available photos and videos into year folders with date-based filenames.
- Reviewed every recovery outcome through a CSV audit report plus a local gallery for supported images and videos.
- Secret Mission: Create a second gallery that shows only one selected year without copying the media again.
Are there any prerequisites?
You need a Mac or Windows computer with Claude Code already installed. You must have permission to inspect the selected backup plus enough free disk space for the recovered media.
Before We Start
This checkpoint commits you to preserving an authorized backup before any tool reads it. You will stop at encryption unless you already know the backup password.
Set Up the Recovery Toolkit
Your read-only recovery promise protects the original backup. A safe workflow also depends on every local tool working before you inspect any backup files.
The recovery script later depends on Claude Code, Python 3.15.2, ExifTool 13.60, plus ios-backup-browser 0.1.4. This step pins each version so the recovery workflow behaves consistently.
You also need Safari on macOS or Microsoft Edge on Windows. That browser displays the local gallery at the end of the project.
In this step, get ready to:
- Verify your existing Claude Code access plus a supported browser.
- Install the pinned Python and ExifTool versions.
- Install the pinned backup reader plus verify the complete toolkit.
Verify Claude Code and browser access
Claude Code creates the recovery script in a later step. Confirming its terminal command now prevents an access problem from interrupting that work.
- Verify Claude Code by running this command:
claude --version
What should I see?
Claude Code prints a version number. This confirms the installed command is available from your terminal.
That is your first check complete. Claude Code is ready to help build the recovery workflow.
macOS
- Press Cmd+Space to open Spotlight.
- Type Safari into the search field.
- Press Enter to open Safari.
You should see a Safari browser window. This confirms the browser is available for your local gallery.
Windows
- Press the Windows key to open Windows search.
- Type Microsoft Edge into the search field.
- Press Enter to open Microsoft Edge.
You should see a Microsoft Edge browser window. This confirms the browser is available for your local gallery.
Install Python and ExifTool
Python runs the recovery script. ExifTool reads capture dates from media metadata so recovered files can receive useful names.
Start by checking which Python version your terminal currently finds.
macOS
- Check your Python version by running this command:
python3 --version
What does this command check?
The command asks the Python installation available as python3 to print its version. You need to see Python 3.15.2 before continuing.
Windows
- Check your Python version by running this command:
py --version
What does this command check?
The command asks the Windows Python launcher to print its selected version. You need to see Python 3.15.2 before continuing.
✔️ I see the required Python version
Good, your Python runtime matches the project pin. You can move directly to the ExifTool check.
ⓧ I see another Python version
Install the pinned release so this project uses the expected Python behavior. The installer may request your computer password or installation permission.
macOS
- Switch back to the browser from earlier.
- Go to the official Python release page for version 3.15.2.
- Select the macOS installer download.
- Open the downloaded installer package from your browser downloads.
- Follow the installer prompts with the default options.
- Start a new terminal window after the installation finishes.
- Confirm the pinned version by running this command:
python3 --version
What should I see now?
The new terminal should print Python 3.15.2. The default installer places the newer Python ahead of the Apple-managed copy.
Windows
- Switch back to the browser from earlier.
- Go to the official Python release page for version 3.15.2.
- Select the recommended Windows installer (64-bit) download.
- Open the downloaded installer from your browser downloads.
- Follow the installer prompts to complete the installation.
- Start a new terminal window after the installation finishes.
- Confirm the pinned version by running this command:
py --version
What should I see now?
The new terminal should print Python 3.15.2. This confirms the Windows launcher can find the pinned installation.
ⓧ Python is not installed
Install Python before adding the backup reader. The installer may request your computer password or installation permission.
macOS
- Switch back to the browser from earlier.
- Go to the official Python release page for version 3.15.2.
- Select the macOS installer download.
- Open the downloaded installer package from your browser downloads.
- Follow the installer prompts with the default options.
- Start a new terminal window after the installation finishes.
- Verify the installation by running this command:
python3 --version
What should I see now?
The terminal should print Python 3.15.2. Python is now available for the project.
Windows
- Switch back to the browser from earlier.
- Go to the official Python release page for version 3.15.2.
- Select the recommended Windows installer (64-bit) download.
- Open the downloaded installer from your browser downloads.
- Follow the installer prompts to complete the installation.
- Start a new terminal window after the installation finishes.
- Verify the installation by running this command:
py --version
What should I see now?
The terminal should print Python 3.15.2. Python is now available through the Windows launcher.
Still seeing the wrong Python version?
Start a new terminal window after installing Python because an existing shell may keep the old command lookup. On macOS, keep the Apple-managed Python installation unchanged.
If python3 still finds an older copy, review the official macOS Python guide for shell path guidance.
Ask for help with the version your terminal selected: Help me make my terminal use Python 3.15.2 without changing the Apple-managed Python installation.
Next, check whether ExifTool is already available from the terminal.
- Check your ExifTool version by running this command:
exiftool -ver
What does this command check?
ExifTool prints its version number without scanning any files. You need to see 13.60 for this project.
✔️ I see the required ExifTool version
Your metadata reader matches the project pin. ExifTool is ready to inspect dates from copied media.
ⓧ I see another ExifTool version
macOS
The package installer may request your computer password. That permission installs ExifTool locally for terminal use.
- Go to the official ExifTool installation page in the browser from earlier.
- Download ExifTool-13.60.pkg.
- Open the downloaded package from your browser downloads.
- Follow the installer prompts to complete the installation.
- Start a new terminal window after the installation finishes.
- Confirm the pinned version by running this command:
exiftool -ver
What should I see now?
The command should print 13.60. This confirms the package installation is available from the terminal.
Windows
This Windows setup is fiddly because ExifTool depends on an adjacent support directory. Keeping both items together prevents the executable from losing required files.
- Go to the official ExifTool installation page in the browser from earlier.
- Download exiftool-13.60_64.zip.
- Extract the downloaded archive.
- Rename exiftool(-k).exe to exiftool.exe.
- Keep the exiftool_files directory beside exiftool.exe.
- Move the executable plus its adjacent directory into a directory on PATH.
The files are now positioned for terminal access. A fresh terminal session loads the updated command path.
- Start a new terminal window.
- Confirm the pinned version by running this command:
exiftool -ver
What should I see now?
The command should print 13.60. This also proves Windows can find the executable through PATH.
ⓧ ExifTool is not installed
macOS
The package installer may request your computer password. That permission installs ExifTool locally for terminal use.
- Go to the official ExifTool installation page in the browser from earlier.
- Download ExifTool-13.60.pkg.
- Open the downloaded package from your browser downloads.
- Follow the installer prompts to complete the installation.
- Start a new terminal window after the installation finishes.
- Verify the installation by running this command:
exiftool -ver
What should I see now?
The command should print 13.60. ExifTool is now available from the terminal.
Windows
This Windows setup is fiddly because the executable must remain beside its support directory. The terminal also needs a command path that contains both items.
- Go to the official ExifTool installation page in the browser from earlier.
- Download exiftool-13.60_64.zip.
- Extract the downloaded archive.
- Rename exiftool(-k).exe to exiftool.exe.
- Keep the exiftool_files directory beside exiftool.exe.
- Move the executable plus its adjacent directory into a directory on PATH.
The files are now positioned for terminal access. A fresh terminal session loads the command path.
- Start a new terminal window.
- Verify the installation by running this command:
exiftool -ver
What should I see now?
The command should print 13.60. ExifTool is now available through PATH.
Terminal cannot find ExifTool?
Start a new terminal window after installation so it reloads the command path. On Windows, confirm that exiftool.exe remains beside the exiftool_files directory.
Check that the directory containing both Windows items is listed on PATH.
Ask for help with your installation: Help me make ExifTool 13.60 available from my terminal.
Install ios-backup-browser and verify the toolkit
The backup reader turns the hidden manifest index into camera-roll records. Its documented API supports unencrypted iOS backups, which matches the recovery boundary you committed to in Step 0.
- Check whether the pinned backup reader is already installed by running this command:
ios-backup --version
What does this command check?
The package exposes the ios-backup command after installation. Its version check should print 0.1.4.
✔️ I see the required backup reader version
The backup reader is already pinned correctly. Your final task is to verify every command together.
ⓧ I see another backup reader version
macOS
- Install the pinned backup reader by running this command:
python3 -m pip install "ios-backup-browser==0.1.4"
What does this command do?
Python runs pip to install the exact ios-backup-browser==0.1.4 package. The exact pin prevents a different release from changing the project behavior.
Package installation failed?
Confirm that python3 --version reports the pinned Python release. A network interruption can also stop the package download.
Ask for help with the installation output: Help me diagnose why ios-backup-browser 0.1.4 did not install with pip on macOS.
Windows
- Install the pinned backup reader by running this command:
py -m pip install "ios-backup-browser==0.1.4"
What does this command do?
The Windows Python launcher runs pip to install the exact ios-backup-browser==0.1.4 package. The exact pin prevents a different release from changing the project behavior.
Package installation failed?
Confirm that py --version reports the pinned Python release. A network interruption can also stop the package download.
Ask for help with the installation output: Help me diagnose why ios-backup-browser 0.1.4 did not install with pip on Windows.
ⓧ Backup reader command not found
macOS
- Install the pinned backup reader by running this command:
python3 -m pip install "ios-backup-browser==0.1.4"
What does this command do?
Python runs pip to install ios-backup-browser==0.1.4 plus the documented terminal command.
Package installation failed?
Confirm that python3 --version reports the pinned Python release. Check your internet connection if the package cannot download.
Ask for help with the installation output: Help me install ios-backup-browser 0.1.4 on macOS.
Windows
- Install the pinned backup reader by running this command:
py -m pip install "ios-backup-browser==0.1.4"
What does this command do?
The Windows Python launcher runs pip to install ios-backup-browser==0.1.4 plus the documented terminal command.
Package installation failed?
Confirm that py --version reports the pinned Python release. Check your internet connection if the package cannot download.
Ask for help with the installation output: Help me install ios-backup-browser 0.1.4 on Windows.
Before you run the final check, which commands do you expect to confirm the pinned versions?
macOS
- Verify the complete macOS toolkit by running these commands:
claude --version
python3 --version
exiftool -ver
ios-backup --version
What should I see?
- Claude Code prints an installed version number.
- Python prints Python 3.15.2.
- ExifTool prints 13.60.
- The backup reader prints 0.1.4.
Windows
- Verify the complete Windows toolkit by running these commands:
claude --version
py --version
exiftool -ver
ios-backup --version
What should I see?
- Claude Code prints an installed version number.
- Python prints Python 3.15.2.
- ExifTool prints 13.60.
- The backup reader prints 0.1.4.
Still missing a version result?
Identify which command failed before changing anything else. Restart the terminal if you installed that tool during this step.
On Windows, recheck that exiftool.exe remains beside exiftool_files in a directory on PATH.
Ask for help with the failed command: Help me diagnose the missing toolkit version result.
Your toolkit is ready for safe local recovery. Every required command now responds before the workflow touches a backup.
Next up, you will locate one authorized iPhone backup. You will see why its hashed folders cannot reveal ordinary photo names on their own.
Find Your iPhone Backup
Your recovery toolkit is ready. Now you need one local backup that you have permission to inspect.
A local iOS backup can be difficult to identify from its folder structure. This step lets you inspect that difficulty safely before any recovery starts.
In this step, get ready to:
- Locate the documented backup folder on your computer.
- Select one authorized backup candidate without changing it.
- Confirm that the candidate has the files required for recovery.
Locate the documented backup folder
Apple stores local backups in different folders on macOS and Windows. Use the path for your platform to reach the existing backups.
macOS
- Press Cmd+Space to open Spotlight search.
- Type Finder and press Enter to open Finder.
- Click Go in the menu bar.
- Select Go to Folder.
- Enter ~/Library/Application Support/MobileSync/Backup/.
- Press Return to open the backup location.
You should see one or more folders representing local iPhone backups.
Windows
- Press Windows+R to open Run.
- Enter %USERPROFILE%\Apple\MobileSync\Backup.
- Select OK to open the location in File Explorer.
- Press Windows+R again if the first location does not open.
- Enter %AppData%\Apple Computer\MobileSync\Backup.
- Select OK to open the alternate location.
You should see one or more folders representing local iPhone backups.
Can't find a backup folder?
- Check that the macOS path includes the spaces in Application Support.
- Try both documented Windows paths if the first location is empty.
- Use the backup management route in the next substep if the iPhone is still available.
Ask for help with finding the local backup folder.
Choose one backup candidate
The device management screen can connect a backup folder to its device and date. Folder dates provide a fallback when the iPhone is unavailable.
Stop at an encryption lock
A lock can feel like a dead end, so treat it as a clear safety boundary. Apple states, You need this password to use the backup.
This project never guesses a password for an encrypted backup.
- Stop if the chosen backup shows a lock unless you already know the correct password.
- Continue with a candidate that has no lock.
- Avoid selecting Delete anywhere in the backup management screen.
Is the iPhone still available?
My iPhone is available
macOS
- Return to Finder.
- Select the iPhone in the Finder sidebar.
- Select General.
- Select Manage Backups.
- Choose the backup with the date you want to recover.
- Select Show In Finder to reveal its folder.
Finder should highlight the backup folder that matches your chosen date.
Windows
- Press the Windows key to open search.
- Type Apple Devices and press Enter to open Apple Devices.
- Select the iPhone in the app sidebar.
- Select General.
- Select Manage Backups.
- Choose the backup with the date you want to recover.
- Select Show in Explorer to reveal its folder.
File Explorer should highlight the backup folder that matches your chosen date.
My iPhone is unavailable
- Return to the backup location from the previous substep.
- Compare the modified dates shown for the backup folders.
- Choose the candidate whose date matches your recovery target most closely.
- Exclude the candidate if you already know it was encrypted and you do not know its password.
Your selected candidate should remain in its original location. Keep every file inside it unchanged.
Inspect the storage layout
The selected folder must contain Manifest.db to support the recovery workflow. Its remaining contents reveal how useful the backup is without an index.
Before you look inside, do you think you will recognize ordinary photo names?
- Double-click the selected backup folder.
- Locate Manifest.db in the folder.
- Scan the surrounding folder names without opening or editing their contents.
You should see Manifest.db beside folders with hash-like names. Ordinary camera-roll filenames are absent.
That shortfall is intentional. The raw storage layout gives you no practical way to identify the photos you want.
Can't find Manifest.db?
A candidate without Manifest.db is legacy, incomplete, or unsupported by this project.
- Return to the main backup location.
- Choose another candidate folder.
- Confirm that the replacement contains Manifest.db.
Ask for help with choosing a supported backup candidate.
The next step needs the selected folder's exact location. Copying its full pathname prevents mistakes when you point the recovery tool at it.
macOS
- Return to the selected backup folder in Finder.
- Click View in the menu bar.
- Select Show Path Bar.
- Control-click the selected backup folder in the path bar.
- Select the pathname-copy command from the context menu.
Windows
- Return to the selected backup folder in File Explorer.
- Press Alt+D to select the address bar.
- Press Ctrl+C to copy the full folder path.
- Record the copied pathname here: <BACKUP_PATH>.
Before the final check, which two visible markers prove that you selected a supported backup?
- Return to the selected backup folder.
- Confirm that Manifest.db is visible.
- Confirm that hash-like backup-content folders are visible.
- Leave the selected folder without renaming, moving, or deleting anything.
That is the key discovery complete. You have found a supported backup while keeping its original contents untouched.
Checkpoint
Your selected iPhone backup is located at <BACKUP_PATH>.
It contains Manifest.db plus hashed backup-content folders. It remains read-only and unchanged.
Your backup candidate and its full path are ready. Next up, you will use a copied Manifest.db index to reveal the hidden camera-roll paths.
Preview the Camera Roll Index
You found an authorized iPhone backup with Manifest.db beside folders with hash-like names. Those hashes protect the backup's storage layout from ordinary browsing, but they also hide the camera-roll paths you need.
The manifest acts as an index between recognizable paths and hashed source files. In this step, Claude Code creates a recovery program that copies the manifest into a separate workspace before reading it. Your original backup stays unchanged.
In this step, get ready to:
- Create the iphone-recovery project with Claude Code.
- Install the pinned project requirement from requirements.txt.
- Preview camera-roll paths through a copied Manifest.db index.
Create the recovery project with Claude Code
Claude Code works inside the folder where its interactive session starts. Keeping that session inside iphone-recovery gives the generated files a clear boundary away from the selected backup.
- Use your computer's file manager to create an empty folder named iphone-recovery on your Desktop.
- Use the file manager's folder context menu to open a terminal inside iphone-recovery.
- Start Claude Code in the current folder by running this command:
claude
What does this command do?
The claude command starts an interactive Claude Code session in iphone-recovery. File changes proposed during this session should remain inside that folder.
- Ask Claude Code to create requirements.txt and recover.py from the project's Complete Code.
- Tell Claude Code to preserve the exact dependency version and every safety check from the Complete Code.
- Review each proposed file path before allowing Claude Code to write it.
- Approve changes only when both file paths remain inside iphone-recovery.
Claude Code should report that it created both project files. The full script includes the copied-manifest preview plus the recovery and gallery capabilities used in later steps.
✔️ Awesome, I've got everything!
- Confirm that requirements.txt appears inside iphone-recovery.
- Confirm that recover.py appears beside requirements.txt.
ⓧ I'd like to double check the full code
Compare Claude Code's files with these complete references. Every line should match before you continue.
ios-backup-browser==0.1.4
What does this file do?
The requirement pins ios-backup-browser to version 0.1.4. This keeps the manifest reader aligned with the API used by recover.py.
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
import os
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from ios_backup import Backup
MEDIA_EXTENSIONS = {
".jpg", ".jpeg", ".heic", ".png", ".gif", ".tif", ".tiff",
".dng", ".webp", ".mov", ".mp4", ".m4v", ".3gp",
}
BROWSER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
BROWSER_VIDEO_EXTENSIONS = {".mov", ".mp4", ".m4v"}
REPORT_FIELDS = [
"status",
"original_relative_path",
"recovered_path",
"date",
"date_source",
"sha256",
"note",
]
def require_tool(name: str) -> str:
path = shutil.which(name)
if not path:
raise SystemExit(
f"Required command '{name}' was not found on PATH. "
"Finish Step 1 and open a new terminal."
)
return path
def default_backup_roots() -> list[Path]:
home = Path.home()
roots = [
home / "Library" / "Application Support" / "MobileSync" / "Backup",
home / "Apple" / "MobileSync" / "Backup",
]
appdata = os.environ.get("APPDATA")
if appdata:
roots.append(Path(appdata) / "Apple Computer" / "MobileSync" / "Backup")
return roots
def backup_candidates(root: Path) -> list[Path]:
if (root / "Manifest.db").is_file():
return [root]
if not root.is_dir():
return []
return sorted(
path
for path in root.iterdir()
if path.is_dir() and (path / "Manifest.db").is_file()
)
def command_scan(_: argparse.Namespace) -> None:
found: set[Path] = set()
for root in default_backup_roots():
for candidate in backup_candidates(root):
resolved = candidate.resolve()
if resolved in found:
continue
found.add(resolved)
modified = datetime.fromtimestamp(candidate.stat().st_mtime)
print(f"{modified:%Y-%m-%d %H:%M:%S} {candidate}")
if not found:
print("No Manifest.db backups were found in Apple's documented locations.")
print("Use Finder or the Apple Devices app to locate a backup, then pass its path directly.")
def copied_manifest_reader(backup_path: Path, workspace: Path) -> Backup:
backup_path = backup_path.expanduser().resolve()
manifest = backup_path / "Manifest.db"
if not manifest.is_file():
raise SystemExit(
"Manifest.db was not found. This is a legacy, incomplete, or unsupported backup."
)
working_index = workspace.expanduser().resolve() / "working_index"
working_index.mkdir(parents=True, exist_ok=True)
shutil.copy2(manifest, working_index / "Manifest.db")
return Backup(str(working_index))
def camera_roll_count(reader: Backup) -> int:
return reader.get_content_count(
domain="CameraRollDomain",
namespace="",
path="Media/DCIM",
like_syntax=False,
)
def camera_roll_records(reader: Backup):
return reader.get_content(
domain="CameraRollDomain",
namespace="",
path="Media/DCIM",
like_syntax=False,
parse_metadata=True,
sorting=True,
)
def command_preview(args: argparse.Namespace) -> None:
backup_path = Path(args.backup)
workspace = Path(args.workspace)
reader = copied_manifest_reader(backup_path, workspace)
try:
count = camera_roll_count(reader)
print(f"Camera-roll index entries: {count}")
print("First paths:")
shown = 0
for record in camera_roll_records(reader):
print(f" {record.relative_path}")
shown += 1
if shown == 10:
break
except Exception as error:
raise SystemExit(
"The copied manifest could not be read. The backup may be encrypted, "
f"damaged, or unsupported. Original error: {error}"
) from error
finally:
reader.close()
def exif_datetime(exiftool: str, path: Path) -> tuple[datetime | None, str]:
result = subprocess.run(
[
exiftool,
"-j",
"-DateTimeOriginal",
"-CreateDate",
"-FileModifyDate",
"--",
str(path),
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None, ""
try:
payload = json.loads(result.stdout)
metadata = payload[0] if payload else {}
except (json.JSONDecodeError, IndexError, TypeError):
return None, ""
for tag in ("DateTimeOriginal", "CreateDate", "FileModifyDate"):
value = metadata.get(tag)
if not value:
continue
try:
return datetime.strptime(str(value)[:19], "%Y:%m:%d %H:%M:%S"), tag
except ValueError:
continue
return None, ""
def choose_datetime(exiftool: str, source: Path, record) -> tuple[datetime, str]:
captured, source_name = exif_datetime(exiftool, source)
if captured:
return captured, source_name
if record.created:
return record.created, "BackupBirthDate"
if record.last_modified:
return record.last_modified, "BackupModifiedDate"
return datetime.fromtimestamp(source.stat().st_mtime), "FilesystemModifiedDate"
def sha256_file(path: Path) -> str:
with path.open("rb") as file_handle:
return hashlib.file_digest(file_handle, "sha256").hexdigest()
def unique_destination(media_root: Path, when: datetime, suffix: str) -> Path:
year_dir = media_root / f"{when:%Y}"
year_dir.mkdir(parents=True, exist_ok=True)
stem = f"{when:%Y-%m-%d_%H%M%S}"
destination = year_dir / f"{stem}{suffix.lower()}"
copy_number = 2
while destination.exists():
destination = year_dir / f"{stem}-{copy_number}{suffix.lower()}"
copy_number += 1
return destination
def write_report(path: Path, rows: list[dict[str, str]]) -> None:
with path.open("w", encoding="utf-8", newline="") as file_handle:
writer = csv.DictWriter(file_handle, fieldnames=REPORT_FIELDS)
writer.writeheader()
writer.writerows(rows)
def media_markup(relative_path: str, filename: str) -> str:
suffix = Path(filename).suffix.lower()
safe_path = html.escape(relative_path, quote=True)
safe_name = html.escape(filename)
if suffix in BROWSER_IMAGE_EXTENSIONS:
preview = f'<img loading="lazy" src="{safe_path}" alt="{safe_name}">'
elif suffix in BROWSER_VIDEO_EXTENSIONS:
preview = f'<video controls preload="metadata" src="{safe_path}"></video>'
else:
preview = '<div class="fallback">Preview unavailable in this browser</div>'
return (
'<article class="card">'
f'{preview}<a href="{safe_path}">{safe_name}</a>'
'</article>'
)
def write_gallery(output_root: Path, recovered: list[dict]) -> Path:
recovered.sort(key=lambda item: item["date"])
groups: dict[str, list[dict]] = {}
for item in recovered:
groups.setdefault(f'{item["date"]:%Y}', []).append(item)
oldest = recovered[0] if recovered else None
oldest_text = (
f'{oldest["date"]:%Y-%m-%d %H:%M:%S} | {oldest["filename"]}'
if oldest
else "No media recovered"
)
sections = []
for year, items in groups.items():
cards = "".join(
media_markup(item["relative_path"], item["filename"])
for item in items
)
sections.append(f"<section><h2>{year}</h2><div class='grid'>{cards}</div></section>")
document = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recovered iPhone Media</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 0; background: #f4f4f6; color: #18181b; }}
header, section {{ max-width: 1100px; margin: auto; padding: 20px; }}
header {{ background: #18181b; color: white; max-width: none; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }}
.card {{ background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 10px #0002; }}
.card img, .card video, .fallback {{ width: 100%; height: 170px; object-fit: cover; background: #27272a; }}
.fallback {{ display: grid; place-items: center; color: white; text-align: center; padding: 8px; box-sizing: border-box; }}
.card a {{ display: block; padding: 10px; color: #1d4ed8; overflow-wrap: anywhere; }}
</style>
</head>
<body>
<header>
<h1>Recovered iPhone Media</h1>
<p>Recovered: {len(recovered)}</p>
<p>Oldest item: {html.escape(oldest_text)}</p>
</header>
{''.join(sections)}
</body>
</html>
"""
gallery_path = output_root / "index.html"
gallery_path.write_text(document, encoding="utf-8")
return gallery_path
def command_recover(args: argparse.Namespace) -> None:
backup_path = Path(args.backup).expanduser().resolve()
output_root = Path(args.output).expanduser().resolve()
output_root.mkdir(parents=True, exist_ok=True)
media_root = output_root / "media"
media_root.mkdir(parents=True, exist_ok=True)
exiftool = require_tool("exiftool")
reader = copied_manifest_reader(backup_path, output_root)
rows: list[dict[str, str]] = []
recovered: list[dict] = []
seen_hashes: dict[str, str] = {}
counts = {"recovered": 0, "missing": 0, "duplicate": 0, "skipped": 0}
try:
total = camera_roll_count(reader)
print(f"Camera-roll index entries: {total}")
for record in camera_roll_records(reader):
relative_path = record.relative_path
suffix = Path(relative_path).suffix.lower()
base_row = {
"status": "",
"original_relative_path": relative_path,
"recovered_path": "",
"date": "",
"date_source": "",
"sha256": "",
"note": "",
}
if record.type != "file" or suffix not in MEDIA_EXTENSIONS:
base_row["status"] = "skipped"
base_row["note"] = "Not a supported photo or video file"
rows.append(base_row)
counts["skipped"] += 1
continue
source = backup_path / record.content_path
if not source.is_file():
base_row["status"] = "missing"
base_row["note"] = "Listed in the manifest but absent from the backup"
rows.append(base_row)
counts["missing"] += 1
continue
digest = sha256_file(source)
base_row["sha256"] = digest
if digest in seen_hashes:
base_row["status"] = "duplicate"
base_row["recovered_path"] = seen_hashes[digest]
base_row["note"] = "Same SHA-256 content as an earlier recovered file"
rows.append(base_row)
counts["duplicate"] += 1
continue
try:
when, date_source = choose_datetime(exiftool, source, record)
destination = unique_destination(media_root, when, suffix)
shutil.copy2(source, destination)
except OSError as error:
base_row["status"] = "skipped"
base_row["note"] = f"Copy failed: {error}"
rows.append(base_row)
counts["skipped"] += 1
continue
recovered_path = destination.relative_to(output_root).as_posix()
seen_hashes[digest] = recovered_path
base_row.update(
{
"status": "recovered",
"recovered_path": recovered_path,
"date": when.isoformat(sep=" ", timespec="seconds"),
"date_source": date_source,
"note": "Copied from backup; source left unchanged",
}
)
rows.append(base_row)
recovered.append(
{
"date": when,
"relative_path": recovered_path,
"filename": destination.name,
}
)
counts["recovered"] += 1
except Exception as error:
raise SystemExit(
"The copied manifest could not be processed. The backup may be encrypted, "
f"damaged, or unsupported. Original error: {error}"
) from error
finally:
reader.close()
report_path = output_root / "recovery_report.csv"
write_report(report_path, rows)
gallery_path = write_gallery(output_root, recovered)
print("Recovery complete")
for status in ("recovered", "missing", "duplicate", "skipped"):
print(f" {status}: {counts[status]}")
if recovered:
oldest = min(recovered, key=lambda item: item["date"])
print(f" oldest: {oldest['date']:%Y-%m-%d %H:%M:%S} | {oldest['filename']}")
print(f" report: {report_path}")
print(f" gallery: {gallery_path}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Recover camera-roll media from an unencrypted local iPhone backup."
)
commands = parser.add_subparsers(dest="command", required=True)
scan_parser = commands.add_parser("scan", help="List backups in documented locations")
scan_parser.set_defaults(func=command_scan)
preview_parser = commands.add_parser("preview", help="Preview camera-roll paths")
preview_parser.add_argument("backup", type=Path)
preview_parser.add_argument("workspace", type=Path)
preview_parser.set_defaults(func=command_preview)
recover_parser = commands.add_parser("recover", help="Recover and organize media")
recover_parser.add_argument("backup", type=Path)
recover_parser.add_argument("output", type=Path)
recover_parser.set_defaults(func=command_recover)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
What does this program do?
The preview path copies Manifest.db into recovery-output/working_index before opening it with Backup. It queries camera-roll records through CameraRollDomain and Media/DCIM.
The remaining functions prepare the same program for metadata-based naming, SHA-256 duplicate detection, CSV reporting, and a local gallery. Those recovery capabilities stay dormant while you run the preview command.
Missing one of the project files?
Check that the Claude Code session started inside iphone-recovery. Ask it to recreate the missing file from the Complete Code without changing the other file.
Confirm that your file manager is showing iphone-recovery on the Desktop. A file written into another folder will not be available to the project commands.
Help me check why Claude Code did not create the expected project files.
Install the pinned project requirement
The project file pins the same backup reader you verified earlier. Installing from requirements.txt ensures this project resolves version 0.1.4 before the preview runs.
macOS
- Install the requirement listed in requirements.txt by running this command in the iphone-recovery terminal:
python3 -m pip install -r requirements.txt
What does this command do?
Python asks pip to read the dependency pin from requirements.txt. The project environment then uses the documented backup-reader version.
The installation may take a moment while Python checks the package. You should see the command finish and return control to your terminal.
Did the requirement installation fail?
Confirm that the terminal is still inside iphone-recovery. The command cannot read requirements.txt from another folder.
Help me diagnose a macOS requirement installation failure.
Windows
- Install the requirement listed in requirements.txt by running this command in the iphone-recovery terminal:
py -m pip install -r requirements.txt
What does this command do?
The Windows Python launcher asks pip to read the dependency pin from requirements.txt. The project environment then uses the documented backup-reader version.
The installation may take a moment while Python checks the package. You should see the command finish and return control to your terminal.
Did the requirement installation fail?
Confirm that the terminal is still inside iphone-recovery. The command cannot read requirements.txt from another folder.
Help me diagnose a Windows requirement installation failure.
Preview the copied camera-roll index
The preview command gives the manifest a separate workspace named recovery-output. The program copies only the index into working_index before asking the backup reader for camera-roll records.
Before you run the preview, what do you expect the readable manifest paths to reveal that the hashed folders could not?
macOS
- Preview the selected backup's camera-roll index by running this command:
python3 recover.py preview "<BACKUP_PATH>" recovery-output
What does this command do?
- The preview argument selects the read-only index workflow.
- The <BACKUP_PATH> argument points to the authorized source backup you located earlier.
- The recovery-output argument provides a separate destination for the copied manifest.
Windows
- Preview the selected backup's camera-roll index by running this command:
py recover.py preview "<BACKUP_PATH>" recovery-output
What does this command do?
- The preview argument selects the read-only index workflow.
- The <BACKUP_PATH> argument points to the authorized source backup you located earlier.
- The recovery-output argument provides a separate destination for the copied manifest.
You should see a camera-roll entry count followed by up to ten paths beginning with Media/DCIM. This is the readable structure that the hashed storage folders concealed.
- Inspect recovery-output/working_index in the iphone-recovery folder.
- Confirm that the folder contains its own Manifest.db copy.
- Return to the selected backup in your file manager.
- Confirm that no project folders or recovered files were created inside the source backup.
That is the safety boundary working. Your program can now reveal camera-roll records while keeping every query away from the original manifest.
Did the preview stop?
Confirm that <BACKUP_PATH> points directly to the backup folder containing Manifest.db. A parent folder or shortened path will not identify the selected backup.
An unreadable copied manifest causes the program to stop instead of attempting decryption or repair. Use another authorized unencrypted backup if this candidate is encrypted, damaged, or unsupported.
Help me diagnose why the copied iPhone backup manifest cannot be previewed.
Your copied index has turned hashed storage into recognizable camera-roll paths. Next, you will use those records to recover media into a separate audited collection.
Recover and Audit Your Media
Your preview proved that a copied Manifest.db can reveal camera-roll paths while the original backup stays untouched. That was the key safety proof for everything you do next.
A list of paths still leaves your photos trapped behind hashed storage names. This step copies the available media into folders you can browse.
Recovery also needs evidence about every examined file. A CSV audit trail records what was recovered or left behind.
In this step, get ready to:
- Confirm the recovery script preserves its read-only boundaries.
- Recover supported camera-roll files into dated year folders.
- Audit the recovery report against several recovered files.
Review the recovery safety boundaries
The completed recover.py workflow handles files from your backup. Before it copies media, Claude Code will trace each safety boundary through the script.
The review focuses on the copied database plus the output folder. It also checks how ExifTool runs.
- Switch back to the Claude Code session from earlier.
- Ask Claude Code to review recover.py without editing it.
- Ask it to confirm that only recovery-output/working_index/Manifest.db is queried.
- Ask it to confirm that source media is only read.
- Ask it to confirm that every write stays below recovery-output.
- Ask it to confirm that ExifTool receives an argument list with no shell.
- Ask it to confirm that the program never accepts or guesses a password.
You should see Claude connect the copied-index rule to copied_manifest_reader(). It should connect the remaining boundaries to exif_datetime() and command_recover().
That review locks in the recovery boundary. Your original backup remains the read-only source of truth.
Why query a copied manifest?
The manifest maps readable camera-roll paths to hashed source files. Querying the copy keeps database activity inside recovery-output.
The script reads media bytes from the authorized backup. Every recovered file goes to a separate destination.
Is the review too vague?
Ask Claude to cite the exact function responsible for each safety rule. Request a direct explanation of where every input is read and where every output is written.
Use this if you want help checking the review: Help me verify the read-only boundaries in recover.py.
Recover and organize the media
The script first looks for embedded date metadata. It falls back to backup timestamps when that metadata is unavailable.
A SHA-256 digest identifies duplicate content before another copy is written. The chosen date places each recovered file in recovery-output/media/<year>/.
This next command writes only beneath recovery-output, so the authorized backup remains untouched. Large collections can take a while because every available media file is hashed and copied.
Before you run it, do you expect every camera-roll record to become a recovered file?
macOS
- Recover the media with the macOS Python launcher by running this command:
python3 recover.py recover "<BACKUP_PATH>" recovery-output
What does this command do?
- The recover command copies the manifest into the working index before querying it.
- The first path identifies the authorized source backup.
- The recovery-output argument keeps every generated file below one output folder.
When the command finishes, you will see Recovery complete. The next lines show separate counts for recovered, missing, duplicate, and skipped.
You will also see the report path plus the gallery path. An oldest-item line appears when at least one file was recovered.
Did the recovery stop?
Confirm that ExifTool remains available in your terminal. Check that the destination disk has enough free space for the recovered media.
An unreadable copied manifest can indicate an encrypted or unsupported backup. Help me diagnose why the recovery command stopped.
Windows
- Recover the media with the Windows Python launcher by running this command:
py recover.py recover "<BACKUP_PATH>" recovery-output
What does this command do?
- The recover command copies the manifest into the working index before querying it.
- The first path identifies the authorized source backup.
- The recovery-output argument keeps every generated file below one output folder.
When the command finishes, you will see Recovery complete. The next lines show separate counts for recovered, missing, duplicate, and skipped.
You will also see the report path plus the gallery path. An oldest-item line appears when at least one file was recovered.
Did the recovery stop?
Confirm that ExifTool remains available in your terminal. Check that the destination disk has enough free space for the recovered media.
An unreadable copied manifest can indicate an encrypted or unsupported backup. Help me diagnose why the recovery command stopped.
You made the jump from hidden backup records to usable media. Your recovered files now have dated names plus an audit result.
How are recovery dates chosen?
- DateTimeOriginal takes priority when the media contains an original capture date.
- CreateDate becomes the next metadata source.
- FileModifyDate provides the final ExifTool metadata option.
- BackupBirthDate uses the creation timestamp recorded by the backup.
- BackupModifiedDate uses the backup modification timestamp.
- FilesystemModifiedDate provides the final fallback from the source file.
The chosen date creates filenames in the YYYY-MM-DD_HHMMSS format. Existing destinations receive -2, -3, or a later number.
Audit the recovery report
The report turns the recovery into an inspectable record. Each row connects an original camera-roll path to its outcome.
Before the final audit, which report details do you think prove that a recovered file matches its source record?
- Switch back to the Claude Code session from earlier.
- Ask Claude Code to read recovery-output/recovery_report.csv without changing it.
- Ask it to display the header status,original_relative_path,recovered_path,date,date_source,sha256,note.
- Ask it to summarize the rows for each recorded status.
- Ask it to select at least three recovered rows for spot-checking.
- Ask it to show the original path plus every audit field for each selected row.
What does each status mean?
- recovered means the script copied a supported source file into a dated year folder.
- missing means the manifest listed a file that was absent from the backup.
- duplicate means the SHA-256 content matched an earlier recovered file.
- skipped means the record was unsupported or could not be copied.
- Ask Claude Code to confirm that each selected recovered_path exists below recovery-output.
- Ask it to compare each selected year folder with the row's date value.
- Ask it to confirm that recovery-output/index.html exists.
- Ask it to confirm that recovery-output/working_index/Manifest.db remains the copied index.
You should see a report row for every examined camera-roll record. The checked files should exist in four-digit year folders that match their recorded dates.
Your recovery is now both usable and auditable. The source backup remains unchanged.
Does a spot-check look inconsistent?
Confirm that Claude selected rows whose status is recovered. Missing or skipped rows do not have a recovered file to inspect.
Check the full relative path because files with similar timestamps can receive numbered suffixes. Help me investigate a report mismatch safely.
Your recovered collection is organized by year with a complete audit trail. Next up, you will open the generated gallery and browse the collection chronologically.
Browse the Recovery Gallery
Your recovered media now has date-based filenames, year folders, and an audit trail. The next challenge is turning those files into a collection you can inspect quickly.
A local Python HTTP server lets your browser load the generated gallery. The loopback address keeps access on your computer.
In this step, get ready to:
- Start a local-only server for the recovery gallery.
- Inspect recovered media in your browser.
- Verify the gallery summary against the CSV audit report.
Start the local gallery server
The server exposes the files inside recovery-output through a browser-friendly local address. Your original backup remains outside the server directory.
The command keeps its terminal occupied while the gallery is available. Leave that terminal running until you finish your checks.
macOS
- Switch back to the terminal in your iphone-recovery folder from earlier.
- Start the local gallery server by running this command:
python3 -m http.server --bind 127.0.0.1 --directory recovery-output 8000
What does this command do?
- The --bind 127.0.0.1 option limits the server to your computer.
- The --directory recovery-output option serves the generated gallery and recovered media.
- The 8000 value selects the local port used by the gallery address.
- Keep this terminal running while you inspect the gallery.
- Confirm that the terminal reports a server running on port 8000.
Windows
- Switch back to the terminal in your iphone-recovery folder from earlier.
- Start the local gallery server by running this command:
py -m http.server --bind 127.0.0.1 --directory recovery-output 8000
What does this command do?
- The --bind 127.0.0.1 option limits the server to your computer.
- The --directory recovery-output option serves the generated gallery and recovered media.
- The 8000 value selects the local port used by the gallery address.
- Keep this terminal running while you inspect the gallery.
- Confirm that the terminal reports a server running on port 8000.
Server not running?
Confirm that the terminal is in the iphone-recovery folder. The recovery-output directory must be inside that folder.
Another local process may already be using port 8000. Stop that process before running the gallery command again.
Help me diagnose why my local recovery gallery server is not starting.
Inspect previews and file links
Your static HTML gallery groups recovered items by year. Each card provides either an inline preview or a direct file link.
- Switch to Safari on macOS or Microsoft Edge on Windows.
- Enter http://127.0.0.1:8000 in the address bar.
- Press Enter to load the gallery.
You'll see the recovered total near the top of the page. You'll also see the oldest recovered item followed by year-grouped media cards.
- Locate a card containing a browser-previewable image.
- Confirm that the image appears inside its card.
- Play one recovered video if your collection includes one.
- Open one direct file link if no recovered video is available.
- Locate a fallback tile if your collection includes a format without an inline preview.
- Open the filename link beneath the fallback tile.
Supported images appear directly inside their cards. Videos use browser controls when their format can be previewed.
Why do some files show a fallback tile?
Browser preview support varies by media format. Files such as .heic can appear as fallback tiles.
The filename link remains available beneath every fallback tile. This keeps recovered files reachable even when the browser cannot display them inline.
Gallery not loading correctly?
Confirm that the server terminal is still running. A returned terminal prompt means the server has stopped.
Check that the browser address is exactly http://127.0.0.1:8000. A missing port opens a different location.
Help me troubleshoot missing previews or broken file links in my local recovery gallery.
Match the gallery to the audit report
The gallery gives you a visual summary of recovered items. The recovery_report.csv file lets you verify that summary against individual audit rows.
- Record the number beside Recovered in the gallery header: gallery recovered total.
- Record the filename beside Oldest item in the gallery header: gallery oldest filename.
- Open recovery-output/recovery_report.csv in a spreadsheet app.
- Filter the status column to recovered.
- Before you compare them, predict whether both gallery values will match the report exactly.
- Compare the number of filtered rows with gallery recovered total.
- Sort the date column from earliest to latest.
- Compare the filename at the end of the first recovered_path value with gallery oldest filename.
Both values should match the gallery header. You have closed the audit loop between the visual collection and its recovery report.
The local server has finished its job. Stopping it releases port 8000 without changing any recovered files.
- Switch back to the terminal running the local server.
- Press Ctrl+C to stop it.
You'll see the server command end. Your terminal prompt returns while every file inside recovery-output remains in place.
Your recovered collection is now browsable, auditable, and safely stored outside the original backup.
Secret mission
Build a One-Year Gallery Filter
Your full recovery gallery can hold media from many years. In this mission, you will reuse the audit report to generate a second gallery for one selected year without scanning the backup or copying media again.
Clean Up Your Resources
Clean Up Your Resources
Your recovery files stay local, so they use disk space without creating an ongoing cloud charge. Choose whether to keep everything available, pause the local server, or remove files you no longer need.
Resources you used:
- Local HTTP server used to display the galleries.
- Copied manifest index at recovery-output/working_index/Manifest.db.
- Recovered media, audit report, main gallery, and selected-year gallery inside recovery-output.
- Project files requirements.txt, recover.py, and year_gallery.py.
Keep everything running
No action needed. Choose this if you are still reviewing the recovery or want to generate more selected-year galleries.
- Keep the terminal session running while you browse the local galleries.
- Keep requirements.txt, recover.py, and year_gallery.py for future recovery work.
- Keep recovery-output as the organized copy of your recovered collection.
- Leave the original iPhone backup untouched in its current location.
Pause - I'll come back to this later
Stop the local server to free its terminal session. Every project file remains available for your next review.
- Switch back to the terminal running the local server.
- Press Ctrl+C to stop the server.
- Confirm that the command prompt returns in the terminal.
Your gallery is safely paused. The recovered media, report, and generated pages remain on your computer.
Delete - I don't want to use this again
Removing the project can erase the easiest copy of your recovered media. Full deletion is appropriate only when another verified copy contains the entire recovered collection.
Protect Your Recovered Copy
This cleanup covers only files created inside the project folder. Your source iPhone backup remains in its original location.
Deleting only recovery-output/working_index preserves the recovered media, report, and galleries. Deleting all of recovery-output removes its media and every generated page.
macOS
Two cleanup levels are available on macOS. The smaller cleanup removes only the copied manifest index.
Keep the recovered collection
- Return to the project folder from earlier in Finder.
- Move recovery-output/working_index to the Trash.
- Empty the Trash in Finder.
That trims the disposable index copy while your recovered collection stays ready to browse.
Remove the entire local project
- Switch back to the terminal running the local server if it is still active.
- Press Ctrl+C to stop the server.
- Check another copy of recovery-output for its media folder and recovery_report.csv.
You should see the year-organized media folder beside the audit report. This confirms that the recovery exists somewhere outside the copy you are about to remove.
- Return to the project folder from earlier in Finder.
- Move recovery-output to the Trash.
- Move requirements.txt, recover.py, and year_gallery.py to the Trash.
- Review the files in the Trash.
- Empty the Trash in Finder.
The project-created files are now removed. Your original iPhone backup remains untouched.
Windows
Two cleanup levels are available on Windows. The smaller cleanup removes only the copied manifest index.
Keep the recovered collection
- Return to the project folder from earlier in File Explorer.
- Delete recovery-output/working_index from File Explorer.
- Empty the Recycle Bin.
That removes the disposable index copy while your recovered collection stays ready to browse.
Remove the entire local project
- Switch back to the terminal running the local server if it is still active.
- Press Ctrl+C to stop the server.
- Check another copy of recovery-output for its media folder and recovery_report.csv.
You should see the year-organized media folder beside the audit report. This confirms that the recovery exists somewhere outside the copy you are about to remove.
- Return to the project folder from earlier in File Explorer.
- Delete recovery-output from File Explorer.
- Delete requirements.txt, recover.py, and year_gallery.py from File Explorer.
- Review the files in the Recycle Bin.
- Empty the Recycle Bin.
The project-created files are now removed. Your original iPhone backup remains untouched.
Nice Work!
Nice Work!
You made it! You used Claude Code to turn an authorized iPhone backup into a safe recovery collection with a clear audit trail.
You've learned how to:
- Protect the original backup with a read-only recovery workflow that queries only the copied working_index/Manifest.db index. You kept every source-backup file unchanged.
- Recover camera-roll media into year-organized folders. You used ExifTool metadata to choose useful dates. You applied content hashing to identify duplicates. You recorded every outcome in recovery_report.csv.
- Browse the recovered collection through a static HTML gallery grouped by year. You can preview supported media. You can reach other formats through direct file links. You can compare the recovered total with the audit report.
- Secret Mission: Build a one-year gallery from recovery_report.csv without rescanning the backup or copying the media again.
Ready to quiz yourself?