I wrote a script that imports photos from iPhone into my Linux machine, converts HEIF/HEIC images into regular JPGs and MOV videos into VP9 videos in MKV.
While I was doing that, I learnt a bit about rsync
, HandBrackeCLI
, libheif
and I'm sharing it here with the script itself.
Prerequisites
First of all, the tools that I needed to install. I'm using Arch Linux so all the package names and commands are Arch-specific.
Packages:
-
libheif
also provides command line utilities that are able to convert images from HEIF (HEIC) format to JPG and PNG -
HandBrackeCLI
is a tool that is capable of convert all kinds of audio/video formats into one another.
Requirements
I want a tool that does the following:
- Downloads all the photos from my iPhone into specified directory and takes note of what it has downloaded
- Converts HEIF/HEIC images into normal JPGs
- Converts MOV videos into VP9 videos in MKV container.
- Moves the original images/videos into archive directory which I can later cleanup or keep for some time for reference.
- I must be free to delete any of downloaded photos from the directory and script must not re-download them again.
Implementation
Let's call our tool phsync
for “photos synchronizer”.
First, several notes on directories structure. iPhone is trying to represent
itself as a photo camera when you plug it into USB port so whenever you mount
it with ifuse
, photos are in DCIM
directory there.
There can be multiple subdirectories in DCIM
directory but file names in them
never clash with one another so we can just dump all of the nested files into
single target directory. I think the multiple subdirectories thing is done for
performance reasons.
I'm going to need several directories:
- Cache directory - the root of all the temporary storage:
${HOME}/.cache/phsync
- Mount directory - where to mount the phone:
${CACHE_DIR}/phone
- Source directory - where the photos are:
${MOUNT_DIR}/DCIM
- Originals directory - where we put originals of converted files:
${CACHE_DIR}/originals
- Photos directory - where I store my photos:
${HOME}/Pictures/Photos
- Target directory - where to download photos:
${PHOTOS_DIR}/Camera
And I'm going to need one file, I'll explain later why:
rsync.done
:${PHOTOS_DIR}/rsync.done
Now, I'll split process into multiple functions that the main script executes.
I'll be writing the script in most straight-forward way, no fancy bash features unless I really need them.
Get ready
First, lets ensure that we have all the environment ready:
validate_setup() {
log "Validating the environment..."
if which heif-convert; then
log "Found 'heif-convert' installed"
else
error "'heif-convert' is required to convert photos, install libheif"
fi
if which HandBrakeCLI; then
log "Found 'HandBrakeCLI' installed"
else
error "'HandBrakeCLI is require to convert videos"
fi
}
OK, lets make sure that all the directories and files are present:
ensure_dirs() {
log "Preparing directories..."
mkdir -p "${MOUNT_DIR}"
mkdir -p "${TARGET_DIR}"
mkdir -p "${ORIGINALS_DIR}"
touch "${RSYNC_DONE}"
}
And finally, mount the phone:
mount_phone() {
log "Mounting phone to: $MOUNT_DIR"
if ifuse "$MOUNT_DIR"; then
log "Phone is mounted at: $MOUNT_DIR"
else
error "Failed to mount phone at: $MOUNT_DIR"
fi
}
Now we're ready to download photos:
sync_photos() {
local target="$TARGET_DIR"
if [ $target == "" ]; then
error "Target can't be empty"
fi
log "Synchronizing photos [target=$target]"
if find "$SOURCE_DIR" -mindepth 1 -type d -name "*APPLE" -exec rsync \
-azvhP \
--size-only \
--ignore-times \
--no-perms \
--no-owner \
--no-group \
--no-times \
--exclude-from="$RSYNC_DONE" \
{}/ \
"$target" \;; then
log "Photo sync completed"
else
umount_phone
error "Failed to sync photos"
fi
}
The if
statement is a bit hard to read, so what here what it does in plain
English:
I'm using find
command to find all the subdirectories in DCIM
directory
- I don't want to see
.
and..
directories, so-mindepth 1
protects me from that DCIM
directory has some hidden directories so I also do name filtering:-name "*APPLE"
- And just to make sure we only get directories, we filter by type:
-type d
means I want to find directories only.
When the find
finds something, I tell it to execute a command with that something:
-exec
.
Note that find requires -exec
arguments to end with ;
which in combination
with shell syntax gives us \;
so everything between -exec
and \;
is the command
find
will run for every found item. Find will replace {}
with the found item.
In this case I'm running rsyc
, lets go through the options I supply and
what they do:
-a
is for “archive”, equals to combination of-rlptgoD
which is just: recursive, copy symlinks as symlinks, preserve permissions, preserve modification times, preserve group, preserve owner and-D
has no effect in this case.-z
is for “compress”, compress file data during the transfer. I doubt it has any effect in this case-v
is for increased verbosity-h
is for having numbers in human-readable format-P
is for--partial --progress
which makesrsync
to show progress and to keep partially transferred files so it can pick up where it's left if you need to interrupt the transfer.--size-only
makesrsync
to only look at the size of files to figure if they need to be transferred. iPhone mounted file system is weird, so we need a simple check only.--ignore-times
makesrsync
to not skip transfer for files that have same modification timestamp. iPhone mounted file system is weird so we can't rely on modification timestamp to be correct.--no-perms
for do not preserve permissions. Again, iPhone file system quirks.--no-owner
,--no-group
and--no-times
is the same for the same reason.
Last two arguments:
{}/
is source directory./
in the end is important, without itrsync
will create{}
directory in the target, with it it'll sync files from{}
to target.$target
speaks for itself, it is where we put our photos.
Now, the very important argument that is missing from the list above is this one:
--exclude-from="$RSYNC_DONE"
this will cause rsync
to read the file $RSYNC_DONE
and skip synchronization
for every file mentioned in that file. One line - one file. This file does
all the magic so we could delete photos and convert them and get rid of originals.
It requires a bit of house keeping on our part, so right after we've downloaded the photos, lets make a list of what we've got:
record_changes() {
log "Marking received photos as 'synchronized'"
find "${TARGET_DIR}" -type f -exec basename {} \; | xargs -I {} /bin/sh -c \
'grep -qxF "{}" '"$RSYNC_DONE"' || echo "{}" >> '"$RSYNC_DONE"
}
again, it is a bit hard to read but what we do here is:
- find all the files in the target directory and for each file:
- check if it present in
$RSYNC_DONE
- if it is missing from
$RSYNC_DONE
insert at the end of that file.
Now that we've got all the photos and we're keeping track of them, time to convert formats.
How do we do that?
libheif
provides a number of command line utilities and one if them is
heif-convert
. It accepts HEIF photo as first argument and output file as
second argument.
The photo format Apple uses called “HEIF” but extension in iPhone file system is “HEIC”. I decided to be on the safe side and write a script that would cover both “HEIF” and “HEIC” extensions.
process_photos_from_to() {
local from="$1"
local to="$2"
log "Converting photos from: $from to: $to"
if [ "$from" == "" ]; then
error "'from' argument is not specified"
fi
if [ "$to" == "" ]; then
error "'to' argument is not specified"
fi
find "${TARGET_DIR}" -type f -name "*.$from" -exec basename -s .$from {} \; | \
xargs -I {} /bin/sh -c \
'heif-convert '"${TARGET_DIR}"'/{}.'"$from"' '"${TARGET_DIR}"'/{}.'"$to"' && mv '"${TARGET_DIR}"'/{}.'"$from"' '"${ORIGINALS_DIR}/"
}
and use it for conversion:
process_photos() {
log "Converting photos..."
process_photos_from_to HEIC JPG
process_photos_from_to HEIF JPG
log "Done converting photos"
}
The conversion script above does the following:
- Finds all the files then end with supplied extension (
HEIC
orHEIF
). - Get the clean names of those files and for each one of them:
- run
heif-convert
to convert the file - and move the file to
$ORIGINALS_DIR
Now that we've got rid of HEIC and have clean JPGs it is time to get rid of MOV and have VP9s.
NOTE: it may take a lot of time to convert videos. One of 4GB videos I had took about 24 hours to convert. Luckily you do it only once and you can actually disconnect the phone right after download is completed.
To convert videos we're going to use HandBrakeCLI
.
HandBrakeCLI
has a lot of presets for every occasion, I chose to use VP9 MKV 2160p60
It is fair bit slower than many other presets but I wanted to preserve the quality of
my videos.
Here is how I convert the videos:
process_videos() {
log "Converting videos..."
find "${TARGET_DIR}" -type f -name "*.MOV" -exec basename -s .MOV {} \; | \
xargs -I {} /bin/sh -c \
'HandBrakeCLI -i '"${TARGET_DIR}"'/{}.MOV -o '"${TARGET_DIR}"'/{}.MKV --preset="VP9 MKV 2160p60" && mv '"${TARGET_DIR}"'/{}.MOV '"${ORIGINALS_DIR}/"
log "Done converting videos"
}
Same as in images case it does:
- find all the files that end with “MOV”
- take file's basename (remove directory and extension) and for each one of them:
- run
HandBrakeCLI
- move the file into
$ORIGINALS_DIR
Now we're ready to wrap it together:
sync() {
validate_setup
ensure_dirs
mount_phone
sync_photos
record_changes
process_photos
process_videos
umount_phone
}
and execute:
sync
Error handling
Something that I don't want is to have to manually unmount the phone if the script crashes. I just want to re-run it and expect it to do the right thing.
So I put this function at the very top of the file:
umount_phone() {
log "Unmounting phone from: $MOUNT_DIR"
if [ "$(mount | grep ifuse | wc -l)" -lt 1 ]; then
error "Nothing to unmount"
fi
while true; do
if fusermount -u "$MOUNT_DIR"; then
log "Phone is unmounted"
break
else
log "Failed to unmount phone, retrying in 1 sec"
sleep 1s
fi
done
}
it does the following:
- Verifies if there is a phone mounted
- and keeps on re-trying to unmount it. Sometimes it takes a bit of time for Linux to understand that the mount point is not being used anymore so we have to re-try several times.
Also, at the very top of the file I set my script to fail on every error:
set -e
and I added a trap that runs umount_phone
function before exiting from the script:
trap "umount_phone; exit" ERR EXIT
Conclusion
There you have it. The script downloads photos from an iPhone, converts photos from HEIF to JPG, converts videos from x264 MOV to VP9 MKV, keeps track of what is synchronized to not restore deleted files.
You can interrupt that script at any point, it will:
- If download is interrupted - it'll resume it.
- If conversion is interrupted - it'll resume it from the file it was interrupted on. Yes, it'll re-convert the whole file but all the converted files stay.
You can find full source of the script here