<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Max Wheeler</title>
    <description>The latest articles on DEV Community by Max Wheeler (@max_wheeler_136997c4effbd).</description>
    <link>https://dev.to/max_wheeler_136997c4effbd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3861608%2Fedcea41b-1483-4a45-943b-64c95fbe3f69.jpg</url>
      <title>DEV Community: Max Wheeler</title>
      <link>https://dev.to/max_wheeler_136997c4effbd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/max_wheeler_136997c4effbd"/>
    <language>en</language>
    <item>
      <title>How I Digitized Years of Home Videos and Photos with Immich</title>
      <dc:creator>Max Wheeler</dc:creator>
      <pubDate>Sun, 05 Apr 2026 21:21:17 +0000</pubDate>
      <link>https://dev.to/max_wheeler_136997c4effbd/how-i-digitized-years-of-home-videos-and-photos-with-immich-2035</link>
      <guid>https://dev.to/max_wheeler_136997c4effbd/how-i-digitized-years-of-home-videos-and-photos-with-immich-2035</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsq23h0nqx99cef49sika.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsq23h0nqx99cef49sika.jpg" alt=" " width="800" height="1157"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Digitized over 20 Years of Home Videos and Photos and Built an AI-Powered Pipeline into Immich
&lt;/h2&gt;

&lt;p&gt;I recently found myself staring at a closet full of MiniDV tapes, a stack of VHS cassettes, and many boxes of printed photos — roughly 20+ years of family memories sitting in formats that are one hardware failure away from being gone forever.&lt;/p&gt;

&lt;p&gt;My wife and I also had growing Apple Photos libraries on our Macs that were eating through iCloud storage. I wanted all of it — the tapes, the prints, the phone photos — in one place, searchable, and under my control.&lt;/p&gt;

&lt;p&gt;The answer for the primary library turned out to be &lt;a href="https://immich.app/" rel="noopener noreferrer"&gt;Immich&lt;/a&gt;, a self-hosted Google Photos alternative, running on a Synology NAS. I chose a two-bay Synology with 6TB of usable storage (two drives configured as a mirror, so a single drive failure doesn't cost me anything) and bumped the RAM to 16GB, which left plenty of headroom for Immich's machine-learning and thumbnail-generation workloads.&lt;/p&gt;

&lt;p&gt;"Under my control" has its own anxieties, though. If I'm replacing iCloud with a NAS sitting in my closet, I want an offsite backup. I set up Synology's Hyper Backup to push everything to AWS S3 Glacier Deep Archive — cheap to store (roughly $1/TB/month), slow to retrieve (hours), which is exactly the right tradeoff for a disaster-recovery copy of 20 years of family memories.&lt;/p&gt;

&lt;p&gt;But getting legacy media &lt;em&gt;into&lt;/em&gt; Immich in a way that's actually useful (with correct dates, descriptions, and organization) required building some tooling. I've open-sourced all of it: &lt;a href="https://github.com/maxwheeler/immich-tools" rel="noopener noreferrer"&gt;github.com/maxwheeler/immich-tools&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's what I built and what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Here's the infrastructure I used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NAS:&lt;/strong&gt; Synology, running Immich in Docker (see the &lt;a href="https://immich.app/docs/install/docker-compose" rel="noopener noreferrer"&gt;official Docker Compose install guide&lt;/a&gt;). I lean heavily on Immich's &lt;a href="https://immich.app/docs/features/libraries" rel="noopener noreferrer"&gt;external libraries&lt;/a&gt; feature so I can keep files in a directory structure of my choosing rather than handing full ownership of storage to Immich.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture hardware:&lt;/strong&gt; Sony DCR-PC100 camcorder (MiniDV via FireWire), VCR, ClearClick Video2USB (VHS tapes), Epson FastFoto FF-680W (photos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing:&lt;/strong&gt; MacBook with &lt;code&gt;ffmpeg&lt;/code&gt;, &lt;code&gt;exiftool&lt;/code&gt;, and Python 3&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI:&lt;/strong&gt; Anthropic's Claude API for scene descriptions&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A quick side quest: CD-ROM backups
&lt;/h3&gt;

&lt;p&gt;Before I got to the tapes and prints, I dealt with a stack of CD-ROMs from the closet — backups I'd burned from a handful of early digital cameras. The good news: those files already had correct EXIF dates, so no scripting was required. The bad news: I hadn't owned a machine with an optical drive in years.&lt;/p&gt;

&lt;p&gt;A $25 &lt;a href="https://www.amazon.com/dp/B07V67STBD" rel="noopener noreferrer"&gt;Amicool External DVD Drive&lt;/a&gt; from Amazon solved it. I copied each disc into its own directory on the NAS, pointed Immich's external library at the parent folder, and the photos slotted into the timeline on their original dates with zero additional work. If only everything had been that easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: MiniDV Tapes
&lt;/h2&gt;

&lt;p&gt;MiniDV was the prosumer video format of the early 2000s. The tapes are small, the quality is surprisingly good (DV codec, 720x480), and the cameras used FireWire for digital transfer — meaning you can capture the original digital stream with no quality loss.&lt;/p&gt;

&lt;h3&gt;
  
  
  Capture
&lt;/h3&gt;

&lt;p&gt;Getting bits off the camcorder took more shopping than I expected. Modern Macs don't have FireWire, so I chained three adapters together to bridge the gap between the Sony DCR-PC100 and a USB-C port:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://www.amazon.com/dp/B087RH9JX8" rel="noopener noreferrer"&gt;PASOW FireWire Cable 9-pin to 4-pin&lt;/a&gt; — connects the camera to the FireWire adapter&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.amazon.com/dp/B008RXYOKY" rel="noopener noreferrer"&gt;Apple Thunderbolt to FireWire Adapter&lt;/a&gt; — converts FireWire to Thunderbolt 2&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.amazon.com/dp/B0DCHHPG2Y" rel="noopener noreferrer"&gt;Apple Thunderbolt 3 (USB-C) to Thunderbolt 2 Adapter&lt;/a&gt; — gets the signal into a modern Mac&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With the hardware sorted, I put the camera in Playback mode and recorded into OBS Studio using its default "Hybrid MOV" output, which produced a clean &lt;code&gt;.mov&lt;/code&gt; file ready to feed into the rest of the pipeline. Two practical tips:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each 60-minute tape produces roughly a 3GB MOV file, so plan disk space accordingly.&lt;/li&gt;
&lt;li&gt;Use OBS's output timer to auto-stop recording after the tape length. I set it slightly long and let the &lt;code&gt;--trim-deadspace&lt;/code&gt; flag on &lt;code&gt;dv_scene_splitter.py&lt;/code&gt; (covered below) handle any trailing static. With that in place I could load a tape, press record, and walk away.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Problem with Raw Captures
&lt;/h3&gt;

&lt;p&gt;A raw tape capture is one giant 60-minute file. That's not useful in a photo library. Each tape contains dozens of distinct scenes — birthday parties, vacations, random Tuesday afternoons — all concatenated together with hard cuts between them.&lt;/p&gt;

&lt;p&gt;I needed to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect where scenes change&lt;/li&gt;
&lt;li&gt;Figure out what each scene contains&lt;/li&gt;
&lt;li&gt;Split the file into individual clips with descriptive names&lt;/li&gt;
&lt;li&gt;Embed metadata that Immich can read&lt;/li&gt;
&lt;li&gt;Set an approximate recording date — pulled from whatever we'd scrawled on the tape label, or an educated guess — so clips land in the right neighborhood on the timeline&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  dv_scene_splitter.py
&lt;/h3&gt;

&lt;p&gt;I built a Python script that handles the entire pipeline. Here's what it does:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scene detection&lt;/strong&gt; uses &lt;a href="https://www.scenedetect.com/" rel="noopener noreferrer"&gt;PySceneDetect&lt;/a&gt; with its &lt;code&gt;ContentDetector&lt;/code&gt;, which analyzes frame-to-frame pixel differences to find hard cuts. DV camcorder footage has clean cuts between scenes (you literally pressed the record button), so this works well out of the box — but the defaults produced too many tiny micro-scenes for my taste, with every pan or exposure change spawning its own clip. After some experimentation I settled on &lt;code&gt;--threshold 45&lt;/code&gt; (a bit less sensitive than the default) and &lt;code&gt;--min-scene-len 20&lt;/code&gt; (ignore any "scene" shorter than 20 seconds), which matched how I actually wanted to browse the footage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python dv_scene_splitter.py tape_001.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--date&lt;/span&gt; 2003-07-04 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trim-deadspace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min-scene-len&lt;/span&gt; 20 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--threshold&lt;/span&gt; 45
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;AI-powered descriptions&lt;/strong&gt; are the interesting part. For each detected scene, the script extracts a representative frame (sampled at 20% into the scene to avoid transition artifacts) and sends it to Claude's vision API. The prompt asks for two things: a short filename-safe label (like "kids_playing_in_backyard") and a detailed 1-2 sentence description.&lt;/p&gt;

&lt;p&gt;This means my clips end up with names like &lt;code&gt;003_birthday_cake_cutting.mov&lt;/code&gt; instead of &lt;code&gt;scene_003.mov&lt;/code&gt;, and Immich can surface them when I search for "birthday" or "cake."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata embedding&lt;/strong&gt; was the trickiest part. I initially tried using FFmpeg's &lt;code&gt;-metadata&lt;/code&gt; flag to set descriptions, but Immich ignores container-level metadata. After some digging, I found that Immich reads &lt;code&gt;ImageDescription&lt;/code&gt; and &lt;code&gt;XMP:Description&lt;/code&gt; — so the script uses &lt;code&gt;exiftool&lt;/code&gt; to write those fields after FFmpeg splits the clip.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-overwrite_original&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-ImageDescription&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Kids blowing out candles on a chocolate birthday cake in the kitchen"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-XMP-dc&lt;/span&gt;:Description&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Kids blowing out candles on a chocolate birthday cake in the kitchen"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-XMP-dc&lt;/span&gt;:Title&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"birthday cake cutting"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  003_birthday_cake_cutting.mov
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Date stamping&lt;/strong&gt; ensures clips appear in the correct position on the Immich timeline. The &lt;code&gt;--date&lt;/code&gt; flag sets a base recording date, and each clip gets that time offset by its position in the tape. So if the tape was recorded on July 4, 2003, scene 1 starts at 14:00:00, scene 2 at 14:03:22, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deadspace detection&lt;/strong&gt; handles a common annoyance with home video: the camera was often left running after the last real scene, recording minutes of lens cap, floor, or a static shot of the couch. The script uses OpenCV to analyze the last few scenes, checking for low pixel variance (blank/uniform frames) and low inter-frame difference (no motion). If detected, those scenes are trimmed automatically with &lt;code&gt;--trim-deadspace&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NAS-friendly staging&lt;/strong&gt; was a late addition after I noticed that writing many small files to an SMB share was painfully slow and sometimes caused write errors. The script now stages all clips locally in &lt;code&gt;~/dv_splits&lt;/code&gt;, then moves them to the output directory in a single batch once all processing is complete.&lt;/p&gt;

&lt;p&gt;A typical tape produces 20-40 clips and takes about 5-10 minutes to process (most of that is the API calls for descriptions). The AI descriptions cost roughly $0.10-$0.50 per tape.&lt;/p&gt;

&lt;h3&gt;
  
  
  VHS: the same tool, a longer errand
&lt;/h3&gt;

&lt;p&gt;VHS capture was the same problem with more errands. The tapes themselves were fine, but nobody in my house had owned a working VCR in at least 15 years — mine had died quietly on a shelf, and the one I eventually used was borrowed from my father-in-law. On the Mac side, a &lt;a href="https://www.amazon.com/dp/B0BVDVZGR2" rel="noopener noreferrer"&gt;ClearClick Video2USB&lt;/a&gt; dongle handled the analog-to-digital hop: VCR composite out → dongle → USB port → capture with the bundled software.&lt;/p&gt;

&lt;p&gt;The resulting &lt;code&gt;.mov&lt;/code&gt; files have VHS-era quality (so don't expect miracles) but they feed into &lt;code&gt;dv_scene_splitter.py&lt;/code&gt; exactly the same way as the MiniDV captures — scene detection, AI descriptions, date stamping, the works. The tooling didn't care about the source format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Scanned Photos
&lt;/h2&gt;

&lt;p&gt;I found someone locally in the SF Bay Area (&lt;a href="https://2ndsprout.com/rental.html" rel="noopener noreferrer"&gt;on Facebook Marketplace&lt;/a&gt;) that rents an Epson FastFoto FF-680W for $50 / day, which is a sheet-fed scanner designed for batch photo scanning. You load a stack of prints and it dumps everything into a folder.&lt;/p&gt;

&lt;p&gt;The workflow I settled on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sort physical photos into groups by approximate date based on notes we had written on the envelopes or occasionally were printed on the back of the photos&lt;/li&gt;
&lt;li&gt;Pre-create date-named folders with &lt;code&gt;create_monthly_folders.py&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Scan each group into its corresponding folder&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;set_photo_dates.py&lt;/code&gt; to stamp the correct dates on all files&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  set_photo_dates.py
&lt;/h3&gt;

&lt;p&gt;This script walks through subdirectories of a root folder, parses the directory name as a date, and sets the creation/modification dates on all photo files inside. It handles a wide variety of date formats — &lt;code&gt;2024-07-04&lt;/code&gt;, &lt;code&gt;July 4 2024&lt;/code&gt;, &lt;code&gt;20240704&lt;/code&gt;, etc.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Preview what will happen&lt;/span&gt;
python set_photo_dates.py /path/to/scanned/photos &lt;span class="nt"&gt;--dry-run&lt;/span&gt;

&lt;span class="c"&gt;# Apply dates&lt;/span&gt;
python set_photo_dates.py /path/to/scanned/photos
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS, it sets both the creation date (using &lt;code&gt;SetFile&lt;/code&gt; from Xcode CLI tools) and the modification date, so when these files land in Immich, they appear in the correct spot on the timeline rather than all clustering on the scan date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Organizing External Libraries in Immich
&lt;/h2&gt;

&lt;p&gt;Immich has a feature called "external libraries" that lets you point it at an existing directory of media without copying the files into Immich's own storage. This is great for a NAS setup — my 1.1TB photo library lives on the Synology's 6TB mirrored volume, and Immich just indexes it in place.&lt;/p&gt;

&lt;p&gt;The limitation is that external libraries don't automatically create albums — and albums matter, because sharing with other Immich users is album-based. If I want my wife to see the "Vacation 2024" photos, those photos need to be in an album. If you have a folder structure like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/photos/
├── Vacation 2024/
├── Christmas 2023/
└── Kids Soccer/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Immich will index all the files but they'll just show up in the main timeline — no album organization.&lt;/p&gt;

&lt;h3&gt;
  
  
  immich_album_from_library.py
&lt;/h3&gt;

&lt;p&gt;This script bridges the gap. Given a directory path (as it appears inside the Immich Docker container) and an album name, it finds all matching assets via the Immich API and adds them to the album.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python immich_album_from_library.py &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--server&lt;/span&gt; http://your-nas:2283 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--api-key&lt;/span&gt; YOUR_API_KEY &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--root-path&lt;/span&gt; &lt;span class="s2"&gt;"/mnt/photos/Vacation 2024"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--album&lt;/span&gt; &lt;span class="s2"&gt;"Vacation 2024"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha that tripped me up: the &lt;code&gt;--root-path&lt;/code&gt; must be the path &lt;em&gt;as seen inside the Docker container&lt;/em&gt;, not the path on your NAS filesystem. Check your &lt;code&gt;docker-compose.yml&lt;/code&gt; volume mounts to get the right path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Apple Photos → Immich
&lt;/h2&gt;

&lt;p&gt;I also wanted my iPhone photos available in Immich without paying for ever-growing iCloud storage. I've since extended this to my wife's library too — she's a blogger with a &lt;em&gt;very&lt;/em&gt; large photo collection, so finally getting off a paid iCloud tier was especially satisfying there. The solution was simpler than the video pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A shell script rsyncs the Apple Photos library from our Macs to the NAS every hour&lt;/li&gt;
&lt;li&gt;A macOS LaunchAgent triggers the sync automatically&lt;/li&gt;
&lt;li&gt;Immich's external library points at the &lt;code&gt;originals&lt;/code&gt; subdirectory inside the synced Photos library package&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key insight is that &lt;code&gt;Photos Library.photoslibrary&lt;/code&gt; is actually a macOS package (a directory that Finder displays as a single file). Inside it, the &lt;code&gt;originals/&lt;/code&gt; subdirectory contains the actual image and video files in a folder structure that Immich can index.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting it up on the Mac
&lt;/h3&gt;

&lt;p&gt;The moving parts are all in the &lt;code&gt;launchd/&lt;/code&gt; directory of the repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;photo-sync.sh&lt;/code&gt; — the actual rsync command, which reads its source/destination/log paths from &lt;code&gt;~/.config/photo-sync/env&lt;/code&gt; so nothing sensitive is hardcoded in the script&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;com.immich-tools.photo-sync.plist.template&lt;/code&gt; — a LaunchAgent plist template with a &lt;code&gt;StartInterval&lt;/code&gt; of 3600 seconds (one hour)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;install-launchd.sh&lt;/code&gt; — an installer that fills in the template with your actual paths, drops the plist into &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt;, and loads it with &lt;code&gt;launchctl&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one-time setup looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One-time: set up SSH key auth so rsync doesn't prompt for a password&lt;/span&gt;
ssh-copy-id user@your-nas-ip

&lt;span class="c"&gt;# Install the LaunchAgent&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;launchd
./install-launchd.sh

&lt;span class="c"&gt;# Edit the generated config with your source/dest&lt;/span&gt;
vim ~/.config/photo-sync/env
&lt;span class="c"&gt;# Set PHOTO_SYNC_DEST="user@nas:/volume1/photos/backup"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the sync runs unattended every hour. Output goes to &lt;code&gt;~/photo-sync.log&lt;/code&gt; so I can sanity-check that it's still working. To remove it later, &lt;code&gt;./install-launchd.sh --uninstall&lt;/code&gt; unloads the agent and removes the plist.&lt;/p&gt;

&lt;p&gt;The SSH key bit is the non-obvious piece — rsync is running from a background LaunchAgent with no terminal, so there's nowhere to type a password. Set up passwordless auth to the NAS first, or the sync will silently fail every hour into the log file.&lt;/p&gt;

&lt;p&gt;This setup means I can delete photos from the Macs to free up local storage and shrink my iCloud plan, while keeping everything accessible and searchable in Immich.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Metadata is everything.&lt;/strong&gt; The difference between a pile of files and a usable library is metadata — dates, descriptions, locations. Invest the time to get it right during ingestion, because fixing it later is painful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Immich reads specific EXIF fields.&lt;/strong&gt; Don't waste time with FFmpeg's &lt;code&gt;-metadata&lt;/code&gt; for descriptions. Use &lt;code&gt;exiftool&lt;/code&gt; to write &lt;code&gt;ImageDescription&lt;/code&gt; and &lt;code&gt;XMP:Description&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External libraries are powerful but have quirks.&lt;/strong&gt; Metadata edits made through the Immich UI on external library assets are stored only in the database — they're not written back to the files. If Immich rescans the library, your edits can be overwritten. Always set metadata on the source files before importing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI descriptions are worth the cost.&lt;/strong&gt; Being able to search "birthday cake" or "beach sunset" across 20 years of home video is genuinely magical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage locally, then move to the NAS.&lt;/strong&gt; Writing many small files over SMB is slow and error-prone. Batch your writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;All the scripts are open source: &lt;a href="https://github.com/maxwheeler/immich-tools" rel="noopener noreferrer"&gt;github.com/maxwheeler/immich-tools&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dv_scene_splitter.py&lt;/code&gt; — MiniDV scene detection, AI descriptions, and splitting&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;immich_album_from_library.py&lt;/code&gt; — Create albums from external library paths&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;set_photo_dates.py&lt;/code&gt; — Stamp dates on scanned photos from folder names&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_monthly_folders.py&lt;/code&gt; — Pre-create date-named folders for scanning&lt;/li&gt;
&lt;li&gt;LaunchAgent for automated Apple Photos → NAS sync&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've got old tapes or photos sitting in a closet, I'd encourage you to start digitizing. The hardware isn't getting any younger, and the process turned out to be more rewarding than I expected — I found footage I'd completely forgotten about.&lt;/p&gt;

</description>
      <category>immich</category>
      <category>selfhosted</category>
      <category>python</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
