Skip to main content
  1. Tech/

Processing 200 GB of Google Takeout on a Synology NAS — Part 2: Fixing the Metadata

Author
Nikhil Joshi

Part 1 ended with 200 GB of Google Takeout extracted on a Synology DS225+, inside a disposable Docker workspace. This part is the harder half: getting Synology Photos to show those files on the right dates, in the right places, with thumbnails.

The received wisdom is “Takeout strips your metadata, so write the sidecars back into every file.” That framing produces a blanket overwrite, and a blanket overwrite is confidently wrong in both directions — it rewrites tens of thousands of files that were already correct, and it silently skips the ones that actually needed help.

So before touching anything I scanned the whole export. It took 75 seconds and changed the design.

What is actually in there
#

40,927 files across 21 flat folders: 21,167 media files and 19,750 sidecars.

jpg 12,295 heic 6,503 mp4 1,451 mov 761
png 124 gif 30 m4v 3 1 with no extension

Eleven Photos from YYYY folders spanning 2014–2024, and nine album folders. And immediately, two things that no tutorial mentions.

Nothing is truncated. The famous Takeout filename-truncation problem — where .supplemental-metadata.json gets cut to .supplemental-.json and eats into the photo’s name — does not occur here at all. The longest sidecar name is 67 characters and every suffix is intact. Worth knowing before you install a tool built to solve it.

4% of the library does not have a sidecar of its own, and that 4% is not random.

The two conventions that break a pattern match
#

A naive matcher looks for <photo>.json or <photo>.supplemental-metadata.json. On my export that leaves 1,690 files unmatched, in two distinct groups.

Duplicates move the marker onto the sidecar. For IMG_0011(1).HEIC the sidecar is:

1
IMG_0011.HEIC.supplemental-metadata(1).json

Not IMG_0011(1).HEIC.supplemental-metadata.json. The (1) sits at the end of the whole sidecar name while the media name inside it stays clean. 272 files, with markers running as high as (18). I had originally implemented the older convention — IMG_1234.JPG(1).json — which my synthetic fixture happily confirmed, because I had written the fixture from the same wrong assumption. Real data is the only thing that catches that class of mistake.

Live Photos ship as a pair with one sidecar. 496 files named IMG_####.MP4 had no sidecar at all. They are the video halves of iPhone Live Photos: 415 sit beside a .HEIC of the same stem, 81 beside a .JPG, and the sidecar belongs to the still. Same capture, so the still’s metadata is the right answer for both.

Add the 922 -edited renders — Google’s edited copies, which also inherit the original’s sidecar — and the final match across all 21,167 files:

1
2
3
4
5
exact         19476   92.01%
edited-copy     922    4.36%
live-photo      496    2.34%
duplicate       272    1.29%
no-sidecar        1    0.00%

One file is genuinely unresolvable: a Live Photo video whose still is missing from the export.

Let the library tell you its own timezone
#

photoTakenTime.timestamp is a UTC epoch. EXIF DateTimeOriginal is local wall-clock with no zone attached. Something has to convert, and the usual exiftool -d %s makes that something the container’s TZ at the moment the command runs — get it wrong and every date shifts by your whole UTC offset.

You do not have to guess. Every file that still has its camera’s timestamp and a sidecar is one measurement of the offset it was taken at:

1
2
3
4
5
6
7
EXIF DateTimeOriginal minus sidecar UTC epoch, 661 files:

   +00:00        6    0.9%
   +05:30      652   98.6%
   +08:00        3    0.5%

use --tz +05:30  (98.6% of the library)

Three clusters, not one — there are holiday photos in there. This is exactly why a single --tz must never be applied to files that already have a date: those 9 travel photos already carry the correct local time their camera recorded, and the offset only ever fills in files that have nothing.

An earlier 2015-only slice of the same library reported 100% at +05:30. The slice was wrong, not the library. Sample stratified, or don’t sample.

The shape
#

Five passes. Nothing is modified until pass 4, and by then you have read pass 3’s plan.

graph LR
    A[1. Inventory
exiftool -csv] --> B[2. Timezone
derive offset] B --> C[3. Plan
match sidecars] C --> D[4. Repair + write
exiftool -@ args] D --> E[5. Verify + move
outcome check] E --> F[verified/] E -.-> G[stays put:
needs work]

Pass 5 is the part most write-ups skip, and it is what makes the whole thing safe to run unattended.

The container
#

Two lines on top of Part 1’s image:

1
2
3
4
5
6
7
8
9
FROM linuxserver/openssh-server:latest

RUN apk add --no-cache p7zip exiftool python3 rsync \
 && exiftool -ver \
 && python3 -c "import sys; print(sys.version)"

COPY takeout_prep.py takeout_verify.py run_pipeline.sh /usr/local/bin/
RUN chmod 0755 /usr/local/bin/takeout_prep.py \
      /usr/local/bin/takeout_verify.py /usr/local/bin/run_pipeline.sh

exiftool lives in Alpine’s community repository, which the LinuxServer base already enables. The version checks turn a repository problem into a failed build rather than exiftool: not found an hour into a detached job.

The orchestrator is POSIX sh, not bash — the container is Alpine, so there is no bash, no local, and no GNU find -printf.

Pass 1: inventory
#

1
2
3
4
5
6
read_pass() {
  exiftool -r -m -q -q -fast2 -i @eaDir -ext '*' --ext json --ext html -csv \
    -FileType -FileTypeExtension -DateTimeOriginal -CreateDate \
    -QuickTime:CreateDate -GPSLatitude -GPSLongitude -XMP:Description "$1"
}
read_pass "$SRC" > "$WORK/inventory.csv"

Four details, each of which cost me a run:

  • -ext '*' — without it ExifTool skips files that have no extension entirely. My export has one, and it was invisible to every pass until I added this.
  • It has to be a function, not a variable. READ_ARGS="... -ext '*' ..." expanded unquoted lets the shell glob * against the working directory. Writing -ext '' instead is worse: it survives word-splitting as two literal quote characters and whitelists only extensionless files, so the read pass returns zero rows and every downstream stage cheerfully treats the library as having no metadata at all.
  • -i @eaDir — Synology’s thumbnail cache. Descend into it and you rewrite DSM’s own generated files.
  • -fast2 stops reading once metadata is out. Across 200 GB of spinning disk that is minutes instead of hours.

Read it with a CSV parser, never awk -F,. ExifTool quotes GPS values, which contain commas, and one of my album folders is called Nikhil, Tejashree. On that row awk -F',' '{print $3}' returns MP4 — the file type, not the date. It does not error; it answers a different question.

Pass 4: repair, then write
#

The plan pass emits an ExifTool argfile — one process, one command per file, separated by -execute. Starting Perl 21,000 times is not a thing to do to a NAS. Three mechanics:

  • -common_args does not work inside an argfile. ExifTool prints Ignored superfluous tag name or invalid option and folds the rest into the last command, which then quietly runs without -overwrite_original and leaves _original copies doubling your disk.
  • No trailing -execute, or ExifTool runs a final command with no files, prints No file specified, and exits non-zero.
  • Chunk it. ExifTool loads an argfile entirely into memory and a DS225+ has 2 GB.

But before any of that, some files need their names repaired.

Extensions that lie
#

Ten .PNG files in my sample are actually JPEGs. ExifTool refuses them outright:

1
Error: Not a valid PNG (looks more like a JPEG) - .../IMG_0088.PNG

There is no “force the format” flag. The fix is to rename to the real extension before writing — but only when the container family differs. Seven .mov files also report as MP4, and those are fine: MOV and MP4 are both QuickTime and ExifTool writes them happily. Renaming on every mismatch would churn files for no reason, so the rule compares families (jpeg/png/heif/qt/riff), not extensions.

The same mechanism fixes the extensionless file: ExifTool identifies it as a MOV, so it becomes IMG_0079.mov and stops being invisible.

One consequence worth stating: whether a file is a video must be decided from the repaired name, not the original one. Decide it from the original and an extensionless MOV gets EXIF tags written into a QuickTime container — which appears to succeed.

The GPS failure that reports success
#

This is the one I would not have caught without checking outcomes:

1
Warning: Error converting value for Keys:GPSCoordinates (PrintConvInv)

Keys:GPSCoordinates accepts "lat lon" space-separated. Add an altitude and the three-value space-separated form fails conversion — while "lat,lon,alt" comma-separated works fine. Because it is a warning, -m swallows it, the file is written, the run reports success, and the coordinates are simply absent. It hit 26 of the 33 videos that had GPS: every one that also had an altitude.

Pass 5: verify by outcome, then move
#

Parsing ExifTool’s log to decide success is the obvious approach and the wrong one. What matters is not what ExifTool said, it is what the file now contains. So pass 5 re-reads every file and asks: does this carry a date Synology Photos can sort on?

With one cross-check. A file that already had a date will pass that test even if it is thoroughly broken — my sample contained a truncated MP4 that sailed through verification on a pre-existing timestamp. So ExifTool’s Error: lines are parsed too, and any file it errored on is held back regardless of what metadata it carries.

Files that pass are moved to a mirror tree. What remains in the source is, by construction, exactly the set still needing work:

1
2
3
ok            768   99.7%   → moved to verified/
no-sidecar      1    0.1%   → Live Photo video with no still
write-error     1    0.1%   → truncated mdat atom

Moving rather than copying is what makes the retry loop cheap. The second run reads 2 files, not 770. Sidecars stay behind, because Synology Photos should never see them and they are what you need to retry a failure.

The two holdbacks are genuinely unfixable. The truncated one lives in a Takeout folder Google named Failed videos — its size on the NAS matches byte for byte, so it arrived broken. Google told me in advance, in a folder name.

Synology Photos gotchas
#

Correct metadata is necessary and not sufficient.

Photos falls back to file modification time. With no usable date it sorts by mtime, and files with nothing land in 1970. Extraction sets mtime to extraction day, so a straight import buries the library under today. Setting FileModifyDate alongside the date is a free second line of defence, and the only lever that works for GIFs and PNG screenshots.

HEIC thumbnails changed in DSM 7.2.2. Synology discontinued server-side HEIC/HEIF thumbnail generation via Advanced Media Extensions, reasoning that end devices decode HEVC themselves; the replacement is client-side, through Synology Image Assistant at upload. With 6,503 HEIC files in my export this is not a footnote. Check Control Panel → Info Center → General before deciding your metadata work broke something.

Album folders duplicate the year folders — but the album-only cost is tiny. 5,620 filenames appear in more than one folder, so importing everything means roughly 6,000 duplicate entries. Yet only 47 files exist solely in an album (21 in Untitled, 14 in Failed videos, 12 in Archive). Import the year folders plus those 47 and you lose nothing measurable. Count it on your own export — find -printf is GNU and absent from the container:

1
2
3
find "$ROOT/Photos from "* -type f ! -name '*.json' | sed 's|.*/||' | sort -u > /tmp/inyear
find "$ROOT"               -type f ! -name '*.json' | sed 's|.*/||' | sort -u > /tmp/all
comm -13 /tmp/inyear /tmp/all | wc -l

Album membership is lost either way: Synology Photos has no bulk album import, and the workarounds go through undocumented internals of the app holding the only copy of your photos.

@eaDir travels. Copy a tree that already contains @eaDir folders and you carry stale thumbnails in.

Indexing is triggered by the write, not by you. Files landing in /volume1/homes/<user>/Photos get picked up automatically; when they don’t, the supported route is Synology Photos → Settings → Personal Space → Indexing → Re-index.

Do the work where the data is
#

I mounted the NAS over SMB to run the scan from my laptop. Reading filenames was fine — 40,927 of them in 75 seconds. Copying bytes was not: 1–4 MB/s, and a 353 MB video died mid-transfer with a bad file descriptor, leaving a truncated file that then failed processing and looked exactly like real corruption until I compared sizes against the source.

At 2 MB/s, 200 GB is about 28 hours. That is Part 1’s whole thesis, measured rather than asserted: cheap metadata reads can happen anywhere, but the bytes never move.

Verified, and not
#

The scan and sidecar matching ran against the complete export — all 40,927 files, all 21,167 media, resolving 21,166 of them.

The processing pipeline ran against a 770-file stratified sample pulled from that export: every year folder and album, all eight file types, duplicate markers 1 through 18, every variant class Google produces (-edited, -ANIMATION, -COLLAGE, -MIX, -MOTION, -SMILE, -Bokeh, ~2), Live Photo pairs, the extensionless file, and the one unmatched file. It processed 768, held back 2, and on a second run read only those 2. Every ExifTool behaviour above — the argfile mechanics, the GPS conversion failure, the PNG refusal, the -ext flags — is observed against ExifTool 13.59, not inferred.

What is not verified is the full 200 GB run, and anything past the NAS filesystem: whether Synology Photos prefers the local or UTC video timestamp, and whether your DSM version generates HEIC thumbnails. Those are properties of your install, and they are what a canary import is for — a few hundred files into Personal Space, checked in the app, before the real copy.

The general lesson is duller than the specifics: every defect worth finding here came from real data, and the two worst ones — a sidecar convention I had assumed, and a GPS write that fails as a warning — were invisible to a synthetic fixture I had written from the same assumptions.

References
#