Личное облако на Proxmox: как собрать себе Google Photos, который никто не отключит

от автора

В прошлой статье я писал как перенести свою музыку себе домой, а это руководство для того, кто решил забрать свой фотоархив домой. От пустого LXC-контейнера до работающего Immich: свой «Google Photos» с умным поиском и лицами на встроенном GPU, доступ с телефона из любой точки мира — и перенос семнадцати тысяч фотографий из Google без потери единого кадра и с восстановлением геолокации.

С чего всё началось

В прошлый раз я собирал себе музыкальный стек вместо YouTube Music — и обещал «другую историю» про фотографии. Вот она.

Мотивация тут даже проще, чем с музыкой. Фотоархив — весь мой архив жил у Google: подписка за хранилище, «умные» функции, которые то появляются, то переезжают в платный тариф, и полная непрозрачность того, что с моими снимками происходит. Плюс у меня накопился второй архив в MEGA — старый телефонный хлам, свадьба друга, сканы документов — который вообще нигде не был разобран.

Захотелось простого: все фотографии в одном месте, на моём диске, с поиском «фото с котом на снегу» без отправки котов в чужое облако. И чтобы телефон продолжал работать как раньше — открыл приложение, а там всё.

Сразу честно: самохостинг фотографий — это добровольное назначение себя бэкап-инженером. Google хотя бы ничего не терял; теперь если потеряю — сам. Поэтому половина этой статьи не про «как поставить Immich», а про сверки, страховки и порядок действий, при котором потерять нельзя. Причем выкачанные архивы я так и не удалю.

Чего мы хотим

Мне не нужен фотохостинг для публики и не нужна «социальность» — нужен личный архив, который переживёт любое облако и чужие продуктовые решения. Максимум это расшарить по ссылке кому то фоточки. Отсюда цели:

  1. Весь архив на своём диске. Ядро — самохостовый Immich: лента, альбомы, карта, умный поиск, лица. Файлы у меня, в RAID, с бэкапами. RAID это просто коробка куда воткнуты два HDD,подключенная к миниПК по usb 3.2 gen2, я в прошлый статьях о ней писал.

  2. ML локально, не в облаке. Поиск по содержимому (не пробовал) и распознавание лиц (пробовал) — на интегрированной видеокарте домашнего сервера. Фотографии не покидают дом ради «умных» функций.

  3. Телефон как раньше. Родное приложение Immich снаружи, из любой сети — но не «порт наружу», а mTLS: без клиентского сертификата сервер для интернета не существует.

  4. Перенос без потерь. Google Photos + MEGA-архив → Immich, со сверками на каждом шаге. Из Google не удаляется ничего, пока целостность не доказана счётчиками. При этом архив выгрузки я естественно вообще удалять не буду никогда.

  5. Вернуть геолокацию. Google прячет координаты при экспорте где может, половина архива вообще снята без GPS. Хочу восстановить географию — честно, с пометкой «откуда взято», не выдавая реконструкцию за правду камеры. Они до 2024 года следили за всеми пользователями постоянно (если кто не знал), но потом говорят, что удалили, но может просто спрятали полную геолокацию, так что тут был пробел и пришлось немного думать.

  6. Выжить после ребута. Сервер перезагрузился — всё поднялось само. Ноутбук админа в этом не участвует.

Архитектура

Одна мысль, из которой растёт вся установка:

Immich — это четыре контейнера и одна папка. Всё, что делает установку «своей», живёт вокруг: проброс iGPU в unprivileged LXC, mTLS на входе и дисциплина наполнения, при которой источник не удаляется, пока копия не доказана.

  ТЕЛЕФОН (Immich-app)              БРАУЗЕР (ноут)       │ api.imm.example.com            │ imm.example.com       │      mTLS :443                 │    mTLS :443       ▼                                ▼  edge-VPS (белый IP) ── WireGuard ── Caddy на роутере (OPNsense)       tls { client_auth CA }   ← без серта отлуп на TLS-хендшейке       api.:  только /api*, /share*, /.well-known/*  (админки нет)       imm.:  полный веб-интерфейс + админка                                        │ plain http, внутренний VLAN  ══════ Proxmox ── LXC «immich» (unprivileged, Docker) ══════                                        ▼   immich_server :2283 ── valkey ── postgres (VectorChord)        │  /data = ./library  (139 ГБ, btrfs RAID1)   immich_machine_learning (-openvino) ◄── /dev/dri (Intel Iris Xe)        CLIP smart-search + лица, всё локально

Со схемы важно прочитать две вещи. Первое: наружу торчит только :443 с обязательным клиентским сертификатом — для сканеров интернета сервера просто нет, и это снимает главный страх «фотографии всей жизни на самодельном сервере». Второе: вся установка — одна папка ~/immich-app (compose, фото, база, кэш моделей), и это осознанное решение: один снапшот-бэкап LXC забирает установку целиком, восстановление — одна операция. Причем весь ML стек это просто встроенная GPU, я как то писал уже статью про это и там говорил, что потом расскажу зачем возня, вот за этим, надо было разобраться.

Контейнер

Образ

Роль

immich_server

ghcr.io/immich-app/immich-server:v3.0.2

Ядро: API, веб, загрузка, фоновые джобы

immich_machine_learning

…immich-machine-learning:v3.0.2-openvino

CLIP-поиск по содержимому, лица. OpenVINO на iGPU

database

ghcr.io/immich-app/postgres:14-vectorchord0.4.3…

Postgres + векторный индекс (родной образ Immich)

redis

valkey/valkey:9

Очереди фоновых задач

Железо скромное: мини-ПК на i5-1240P с интегрированной Iris Xe, диски — 2×8 ТБ USB в btrfs RAID1. Никакой дискретной видеокарты для ML не нужно — и это, пожалуй, главная приятная новость всей затеи.

Установка

Шаг 0. LXC — то, что делается только с хоста

Immich живёт в отдельном unprivileged-LXC (Ubuntu 24.04, docker внутри). Единственное специальное — проброс render-ноды iGPU для ML:

# на хосте Proxmox, под root (116 — номер вашего CT)pct set 116 --dev1 /dev/dri/renderD128,gid=993,uid=1000pct set 116 --features nesting=1pct set 116 --onboot 1

gid=993 — группа render хоста, uid=1000 — обычный пользователь внутри CT: нода приезжает в контейнер уже с правильным владельцем, и дальше всё работает без единого послабления безопасности. Два популярных совета из старых гайдов не понадобились, и это стоит проговорить: apparmor: unconfined не нужен на актуальном Proxmox (баг runc, из-за которого совет появился, в свежих пакетах lxc исправлен), а keyctl=1 Docker в этой конфигурации не требует — проверено запуском: seccomp отдаёт keyctl() как «не реализовано», и никто не жалуется. Чем меньше дырок в изоляции — тем спокойнее спится.

Внутри CT — обычный docker + compose, и дальше всё под обычным пользователем (uid 1000). Root — только для systemd-юнита автозапуска. Управление — sudo docker compose, пользователя в docker-группу не добавляем (членство в ней — это root-эквивалент, sudo хотя бы просит пароль и пишет лог).

Шаг 1. Официальные файлы + четыре правки

Immich ставится из официальных файлов релиза — меньше самодеятельности, легче обновляться, но тут по вкусу:

mkdir -p ~/immich-app && cd ~/immich-appwget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.ymlwget -O hwaccel.ml.yml     https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.ymlwget -O .env               https://github.com/immich-app/immich/releases/latest/download/example.env

В .env правим четыре значения:

UPLOAD_LOCATION=./library      # фото — рядом с compose, в rootfs CTDB_DATA_LOCATION=./postgres    # база — там жеTZ=Europe/AmsterdamIMMICH_VERSION=v3              # мажорный канал, НЕ release (см. грабли); параноикам — пин v3.0.2DB_PASSWORD=<только A-Za-z0-9, без спецсимволов>

Относительные пути: вся установка (ОС, docker, фото, база, кэш моделей) лежит в одном rootfs контейнера, и еженедельный vzdump в режиме снапшота забирает её целиком, crash-consistent — Postgres при восстановлении доиграет WAL сам. Один бэкап = цельная установка.

В docker-compose.yml — две правки под OpenVINO и одна под HDD:

  immich-machine-learning:    container_name: immich_machine_learning    image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-openvino    extends:      file: hwaccel.ml.yml      service: openvino          # /dev/dri уезжает в контейнер отсюда    user: "1000:1000"    group_add: ["993"]           # render-группа: доступ к renderD128 под non-root    environment:      HOME: /cache               # у uid1000 нет HOME → кэши моделей падают без этого    volumes:      - ./model-cache:/cache     # bind вместо named-тома: владелец — вы, не root    env_file: [.env]    restart: always  database:    environment:      DB_STORAGE_TYPE: 'HDD'     # у нас btrfs на USB-дисках, не NVMe

Остальным сервисам добавляем только user: "1000:1000" — весь стек работает под обычным пользователем, включая Postgres (его данные принадлежат uid 1000, он и бежит от uid 1000). Драйверы Intel внутрь CT ставить не нужно: образ -openvino несёт весь рантайм в себе, от хоста требуется только kernel-драйвер i915 и проброшенная нода.

Шаг 2. Запуск и проверка, что ML реально на видеокарте

cd ~/immich-app && sudo docker compose up -d

Четыре контейнера должны стать healthy, веб отвечает на :2283 — создаём администратора. Дальше проверка, ради которой всё затевалось:

sudo docker logs immich_machine_learning 2>&1 | grep -i provider#  → Setting execution providers to ['OpenVINOExecutionProvider', ...]intel_gpu_top   # на хосте, во время джоба Smart Search: движки iGPU должны шевелиться

Если в логе только CPUExecutionProvider — ML работает, но на процессоре, и первичная индексация двадцати тысяч фото будет есть CPU, а GPU будет простаивать (проверять это надо до заливки семнадцати тысяч фото, а не после). Первый прогон разово скачает модели (CLIP + детектор лиц) в model-cache — около 7 ГБ.

Шаг 3. Автозапуск

# /etc/systemd/system/immich.service (в CT, под root)[Unit]Requires=docker.serviceAfter=docker.service network-online.target[Service]Type=oneshotRemainAfterExit=yesWorkingDirectory=/home/<user>/immich-appExecStart=/usr/bin/docker compose up -dExecStop=/usr/bin/docker compose down[Install]WantedBy=multi-user.target

systemctl enable immich.service — и вместе с restart: always у контейнеров и onboot=1 у самого LXC получаем три уровня «поднимись сам»: за «сервер перезагрузился, фотографий нет» просыпаться не любит никто. Ребут-тест обязателен: перезагрузить CT и убедиться, что всё встало без рук — до того, как сервер станет единственным местом с фотографиями.

Шаг 4. Доступ снаружи: mTLS и два хоста

Сначала — почему так строго. У Immich нет ни 2FA, ни защиты от брутфорса. То есть форма логина, торчащая в интернет голым портом, защищена одним паролем — для архива всей жизни этого мало. Значит, вся безопасность живёт на входе.

Наружу Immich выходит через reverse-proxy (у меня Caddy на роутере; до него — VPS с белым IP, который не терминирует TLS, а гонит сырой :443 через WireGuard домой — но это детали конкретной сети, важна сама схема). Ключевое решение — два разных хоста с разной шириной доступа, оба за mTLS:

# Браузер: полный Immich, включая админкуimm.example.com {    tls { client_auth { trust_pool file ca.pem } }    handle { reverse_proxy immich.vlan:2283 }}# Телефон: ТОЛЬКО пути, нужные приложению. Корня нет → админки снаружи нетapi.imm.example.com {    tls { client_auth { trust_pool file ca.pem } }   # тот же серт, что в Android    handle /api/*               { reverse_proxy immich.vlan:2283 }    handle /.well-known/immich* { reverse_proxy immich.vlan:2283 }    handle /share/*             { reverse_proxy immich.vlan:2283 }    handle /_app/*              { reverse_proxy immich.vlan:2283 }    handle /custom.css          { reverse_proxy immich.vlan:2283 }    handle /favicon*            { reverse_proxy immich.vlan:2283 }}

В целом можно и одним обойтись и не заниматься выделением эндпоинтов, я два сделал потому, что поначалу mTLS не выходило в immich добавить так как там UI не очевидный, ниже напишу. ca.pem — собственный CA (создаётся в пару кликов в любом роутере/PKI; у меня — в OPNsense System→Trust), из него же выписываются клиентские сертификаты: .p12 для ноутбука и телефона. Серверный сертификат при этом обычный Let’s Encrypt, так что в доверенные корни телефона ничего добавлять не нужно — ставится только клиентский. Без сертификата соединение умирает на TLS-хендшейке — до HTTP, до какого-либо «login», до самого Immich дело даже не доходит. Сканеры интернета видят закрытую дверь.

Родное приложение Immich клиентские сертификаты поддерживает (Settings → Advanced → «SSL client certificate» import, помечено experimental — но работает, включая фоновый бэкап). С двумя засадами — они в граблях (№6).

Наполнение: Google Photos → Immich без потерь

Теперь главное. Перенос фотоархива — это не «скачал и залил»: из Google ничего не удаляется, пока каждая партия не доказана счётчиками. Весь порядок ниже — в этой логике.

Takeout и его проверка

Экспорт — Google Takeout, только Google Photos, zip по 50 ГБ (максимальный размер части: чем меньше частей, тем реже фото и его JSON-метаданные разъезжаются по разным архивам). Скачивать все части резюмируемым способом, не голой вкладкой браузера — она молча бьёт большие файлы. И первая проверка ещё до всякого Immich:

ls takeout-*.zip | wc -l    # число частей = числу, которое назвал Googlefor f in takeout-*.zip; do unzip -t "$f" >/dev/null && echo "OK $f" || echo "CORRUPT $f"; done

Битую часть — перекачать, пока ссылки живы (~7 дней). Пропущенная средняя часть молча теряет тысячи фото. Архивы не распаковывать: инструмент импорта читает zip напрямую и матчит фото↔JSON↔альбом по путям через все части сразу.

immich-go: импорт, которому нельзя верить на слово

Заливает архив immich-go — специализированный инструмент именно для Takeout. Он берёт дату съёмки, GPS и альбомы из JSON-сайдкаров Google (родной immich upload этого не умеет — читает только EXIF и теряет всё, что Google хранил отдельно):

# сначала dry-run — прочитать сводку: uploaded / duplicate / unmatched / errorsimmich-go upload from-google-photos --server="$IMMICH_SERVER" --api-key="$KEY" \  --dry-run takeout-*.zip# реальный импорт; идемпотентный — при обрыве просто повторитьimmich-go upload from-google-photos --server="$IMMICH_SERVER" --api-key="$KEY" \  --session-tag --on-errors=continue --pause-immich-jobs=true \  --concurrent-tasks=8 --client-timeout=60m takeout-*.zip# добрать файлы, чей JSON Google переименовал (по умолчанию они МОЛЧА пропускаются!)immich-go upload from-google-photos --include-unmatched --session-tag \  --server="$IMMICH_SERVER" --api-key="$KEY" takeout-*.zip

Логу immich-go верить нельзя («uploaded successfully» при недоехавших ассетах). Единственное доказательство полноты — счётчик самого Immich плюс идемпотентный повтор. У меня это выглядело так: первый прогон — 17 165 ассетов, второй добрал 51, третий — ноль новых. Ноль на повторе и есть определение «всё доехало». Итог: 17 212 ассетов (16 651 фото + 561 видео), все альбомы на месте. Сами прогоны, сверки счётчиков и повторы гонял Клод прямо на сервере — я читал отчёты; флаги в командах выше выстраданы именно так.

Отдельно — то, чего в Takeout нет вообще, и что при удалении из Google пропало бы навсегда: Locked Folder и фотографии, которые другие люди добавляли в общие альбомы. Их надо спасать руками до любой очистки (сохранить к себе → залить from-folder). У меня Locked Folder оказался пуст, а чужие фото я решил не тянуть — но проверить это надо явно, а не обнаружить постфактум.

MEGA-архив: дедуп до заливки, а не после

Второй источник — 49 ГБ неразобранного личного архива из MEGA: выгрузки со старых телефонов, свадьба друга с зеркалки, скриншоты, арт, сканы документов. Лить такое в ленту как есть — испортить её навсегда, поэтому порядок другой:

  1. Перцептивный дедуп до заливки. Не по байтам — по картинке: pHash с порогом Hamming ≤ 6 ловит один и тот же кадр в разных пережатиях. 938 дублей отсеялось ещё на ноутбуке. Техническая деталь, без которой ничего не работает: перед хэшированием обязателен exif_transpose — иначе портретное фото, у которого поворот записан в EXIF, даёт расстояние 26 вместо 0 к собственной копии.

  2. Сортировка по судьбе. Живые фото и видео — в ленту. Скриншоты (771), картинки/арт (240), сканы и документы (99) — в альбомы с visibility: archive: они есть, ищутся, но ленту воспоминаний не засоряют.

  3. Заливка и серверный дедуп. Immich сам отбрасывает байт-идентичное (часть архива пересекалась с Takeout). Из 6 639 файлов staging новыми легли 4 875.

Итог наполнения: 22 087 ассетов, 30 альбомов, 139 ГБ. И только теперь можно говорить об очистке Google. При этом конечно все это делал ИИ, скрипты накидал, тут описан только принцип.

Очистка Google — после трёх сверок

Перед удалением — три независимые проверки, каждая ловит свой класс потерь:

  • Импорт полон: повторный прогон immich-go по Takeout = 0 новых; staging MEGA = 0 новых. Всё, что есть в источниках, есть на сервере.

  • Метаданные забраны: ~1,9 тыс. фото с GPS в JSON-сайдкарах ≈ столько же с GPS в Immich. Выборочно по всем годам: дата съёмки — не дата импорта, Live Photos играют, альбомы на месте.

  • Корзина не больше копии: после удаления в корзине Google оказалось 17 080 объектов при 17 212 в Immich. Удалённого не может быть больше, чем сохранённого; разница объяснена (Live Photo у Google — один объект, у Immich — кадр + видео, два ассета).

Само удаление из веб-интерфейса Google Photos — отдельная песня: «выбрать всё» для большой библиотеки там не существует или я не нашел, а массовый Shift-клик в их SPA не работает — интерфейс виртуализирован и мертвеет на массовых операциях. Я поручил это Клоду — он написал скрипт-демона, который через DevTools-протокол управляет моим же залогиненным браузером и делает то же, что делал бы я: выделяет фото, жмёт «В корзину», подтверждает диалоги. Партиями по паре десятков, с человеческими паузами и джиттером, с автостопом при любом намёке на капчу и сторожем-вотчдогом сверху. Семнадцать тысяч фотографий ИИ выкликивал два дня — фоном, пока я занимался своими делами и изредка поглядывал на счётчик корзины. Кстати, о счётчике: своему демону он тоже не верил не зря — счётчик скрипта в итоге наврал, полноту очистки подтвердила только пустая библиотека плюс число объектов в корзине. Ключевое: удалённое падает в корзину Google и лежит там 60 дней — это последняя страховка, и её не трогаем до конца окна. Takeout-архивы (88 ГБ) и MEGA-исходники (27 ГБ) остаются холодной копией на своём диске навсегда — они больше не скачаются. Мне кажется Google специально сделал удаление таким сложным что бы человек забил, ИИ ~1200 раз прогонял цикл, падал и пробовал снова и цикл это много кликов.

Геолокация: вернуть то, что камера не записала

Та самая «другая история». После импорта GPS был примерно у 2 тысяч фото из 22 — остальное снято до эпохи геотегов или телефонами с выключенным GPS. А хотелось карту жизни (в immich прям можно посмотреть карту). К сожалению данные старой слежки Google удалил, я бы по времени мог геотегировать. Сначала начали без спроса следить, а потом так же без спроса удалили собранное, это одна из причин почему я решил все перевести в личное облако.

Восстанавливал слоями, от точного к грубому, и с железным правилом: фото с настоящими координатами не трогаются вообще, а каждый восстановленный геотег несёт в описании пометку об источнике и точности — «геотег по альбому», «интерполяция по времени ≤6ч», «по событию календаря». Через год я смогу отличить правду камеры от моей реконструкции одним взглядом (и скриптом — по регулярке).

  • По альбомам-местам: альбомы, названные городом или страной («Город N: путешествие»), сами говорят, где сняты — фото без GPS внутри получают координаты этого места, с точностью «город» или «регион». ~1 300 фото. Такие альбомы вел Google Photo сам.

  • Интерполяция по времени ≤ 6 часов: фото без GPS берёт координаты ближайшего по времени фото с настоящим GPS. Порог именно 6 часов, не сутки — перелёты: утром одна страна, вечером другая. И якорями служат только настоящие координаты плюс альбомные — интерполяция от интерполяции запрещена, иначе ошибка едет цепочкой.

  • Хронология из Takeout-активности: самый неожиданный источник. «Мои действия» Google помнят каждый построенный маршрут и каждый просмотр карты с координатами прямо в URL — из этого, календаря и реальных EXIF собралась хронология в 15,6 тыс. временных якорей. По сути я восстановил где и чем занимался начиная с 2009 года, даже с учетом того, что Google слежку удалил. Для маршрутов это «в момент T я был здесь», для просмотров карт честнее «смотрел сюда» — и в пометке точности так и написано: «смотрел» ≠ «был». Прогон по хронологии дал ещё 10,8 тыс. геотегов: ~2,7 тыс. по якорям ≤ 6 ч и ~8,1 тыс. по периодам проживания — «в эти годы я жил в этом городе», city-level, с соответствующей пометкой. Перед применением — обязательная проверка: пересечение со множеством фото, у которых координаты уже есть, равно нулю (скрипт останавливается, если не так).

  • Вшивание в файлы: координаты в базе Immich — это хорошо, но файл должен быть самодостаточным (следующая миграция скажет спасибо). Утилита immich-exif пишет восстановленный GPS прямо в EXIF и перезаливает файл; оригиналы уезжают в корзину Immich на 30 дней. Вшиты пока ранние слои — альбомные и интерполяция; свежий хронологический живёт в базе, его вшивание тем же механизмом у меня в списке.

Итог: GPS-покрытие с ~2 до 19,5 тыс. ассетов из 22. Карта в Immich из пары городов превратилась в маршрут жизни: переезды, командировки, отпуска. А совсем размытые слои — самые ранние годы, где даже город под вопросом, — я сознательно не применял: лучше честное «неизвестно», чем красивое враньё.

Всю эту археологию — разбор активности, сверки, скрипты с undo-списками — делал Клод по принципу «сначала отчёт, что и сколько затронет, потом моё подтверждение, потом правка».

Форк ради одной буквы: как не потерять фото при вшивании

Со вшиванием не обошлось без форка. immich-exif гоняет под капотом exiftool, и первый же прогон засыпал лог отказами — 475 штук, все на кадрах с зеркалки:

Warning: Truncated NikonScanIFD directory. IFD dropped.Error: [minor] Bad NikonScanIFD offset for Nikon_Scan_0x002a0 image files updated    1 files weren't updated due to errors

Разбор показал: в maker notes этих JPEG лежит фирменный под-каталог NikonScanIFD с битым смещением — наследие какого-то софта, который когда-то в жизни этих файлов неаккуратно переписал EXIF. Сам exiftool честно помечает ошибку как [minor]: данные не читаются, но они и не нужны — это метаданные древней никоновской программы, не изображение. Однако по умолчанию он отказывается записывать файл, в котором видит хоть какую-то ошибку, даже минорную. Безопасный дефолт, уважаю — но для меня он означал «сотни кадров остаются без геотега в файле навсегда», причём именно тех, ради которых всё затевалось.

У exiftool на этот случай есть штатный флаг -m (ignore minor errors): пиши, что просят, а битый мусорный под-каталог выброси. Проблема — immich-exif не умеет пробрасывать произвольные аргументы в exiftool. Пришлось форкнуть и добавить одну букву в оба вызова в src/exif/tool.go:

// чтение тегов:cmdArgs := append([]string{"-m"}, exifArgs...)// запись:cmdArgs = append(cmdArgs, "-m", "-overwrite_original", "--", filePath)

Пересобрал образ — и весь класс ошибки исчез: 475 никоновских отказов превратились в ноль, финальный прогон закончился строкой Done: 451 succeeded, 0 failed. Честности ради: нашлась ещё пара файлов с другой, уже настоящей поломкой (битая ссылка на встроенное превью в IFD1) — их не спас даже -m, и я оставил их в покое: геотег у них есть в базе, а чинить чужую битую структуру EXIF ради вшивания — уже перебор. Почему это не страшно: пиксели exiftool не трогает вообще, «теряется» только под-каталог, который и так был нечитаемым мусором. Плюс механизм сам по себе страхующий: сначала dry-run с планом правок, а запись идёт через re-upload — старый файл уезжает в корзину Immich на 30 дней, так что даже неудачная запись физически не может ничего уничтожить. И да: перед тем как дать стороннему инструменту API-ключ от всего архива и право перезаливать файлы, я попросил ИИ прочитать его исходники целиком — образ у меня так и называется, immich-exif:audited. Это, кстати, новое в век ИИ, я теперь все утилитки, а так же малоизвестные контейнеры прошу ИИ по исходникам анализировать и проводить аудит на предмет утечек и безопасности, скомандовать не сложно, а безопасность внутренней инфраструктуры подтягивается выше чем не читать вовсе.

Грабли

Места, где ИИ потратил время (а однажды — чуть не потерял данные), чтобы вы не потратили.

  1. Геотег до окончания metadataExtraction затирает настоящий GPS. Главная грабля, единственная, где реально пострадали данные. Залил свадьбу (зеркалка с GPS-модулем), сразу проставил альбому координаты центра города — а Immich ещё не успел дочитать EXIF. Ручной геотег победил: настоящие покадровые координаты 1041 фото затёрлись в базе. Хуже того — refresh-metadata вручную проставленное не перезаписывает: файл говорит одно, база другое, и штатного пути назад нет. Лечение — delete + повторная заливка с ожиданием, пока metadataExtraction станет idle, матчинг файлов к ассетам по checksum. Порядок навсегда: залить → дождаться метаданных → только потом геотегать.

  2. Логам инструментов миграции не верить. Полнота доказывается только счётчиком принимающей стороны и идемпотентным повтором до нуля новых. Это спасло от «успешно залито» при реально недоехавших файлах.

  3. pHash без exif_transpose — мусор. Портретные фото хранят поворот в EXIF; без нормализации ориентации перцептивный хэш не совпадает у файла с самим собой.

  4. Live Photos считаются дважды. У Google это один объект, у Immich — кадр + видео. Пока не знаешь — счётчики «не сходятся» и полчаса паники при сверке целостности.

  5. ML под non-root молча деградирует. У uid 1000 в контейнере нет HOME → кэши моделей пытаются писаться в / → нужно HOME=/cache. Named-том кэша принадлежит root → uid 1000 туда не пишет → bind-mount в свою папку. И group_add: ["993"] для render-ноды. Все три — из серии «контейнер зелёный, фичи нет».

  6. Android + mTLS: две мины подряд. Первая: кнопка выбора сертификата в приложении Immich ничего не делает, пока .p12 не импортирован в системное хранилище Android (Настройки → Безопасность → Учётные данные); кнопка импорта в самом приложении выглядит рабочей, но мертва. Вторая: .p12 должен быть в legacy-формате (PBE-SHA1-3DES) — OpenSSL 3 по умолчанию пакует его в AES-256, который Android импортирует молча-неуспешно, но тут я не уверен, кажется работало и с новыми форматами, но для информации.

  7. API-нюансы Immich v3, о которые бьются скрипты: поисковый search/metadata возвращает exifInfo: null (GPS брать из map/markers, полный ассет — из GET /assets/{id}); описание живёт в exifInfo.description, не на верхнем уровне; массовый PUT /api/assets помечен deprecated, но он единственный рабочий способ bulk-правок — поэтому версия в .env держится на мажорном канале v3 (не release), а обновление — осознанный шаг с дампом БД, а не автопул.

  8. Тяжёлые операции — на сервере, не через туннель с ноутбука. Заливка десятков гигабайт через хрупкий туннель (сон ноутбука, обрыв Wi-Fi) виснет и рвётся. Всё тяжёлое гонится на самом CT против localhost. И рабочие файлы скриптов — не в /tmp: это tmpfs, ребут стирает (проверено на себе).

Что в итоге

  • Свой Google Photos. Immich с лентой, альбомами, картой, поиском «закат на море» (не проверял) и лицами (проверял) — на домашнем сервере, ML на интегрированной видеокарте мини-ПК. Переживает ребут без рук.

  • 22 087 ассетов из двух источников — Google Photos и многолетний MEGA-хлам — слиты, дедуплицированы и разобраны: живое в ленте, скриншоты и сканы в архивных альбомах.

  • Ни одного потерянного кадра, и это доказано, а не «вроде всё скопировалось»: идемпотентные прогоны до нуля новых, сверка метаданных, корзина ≤ копии. Google очищен только после всех трёх проверок, страховки (корзина 60 дней, Takeout, MEGA-исходники, дампы БД, RAID, снапшоты) живут до сих пор.

  • География восстановлена честно: GPS-покрытие с 2 до 19,5 тысяч фото, каждый восстановленный геотег помечен источником и точностью, настоящие координаты не тронуты, ранние слои уже вшиты в сами файлы.

  • Снаружи сервера не существует. Только :443 с обязательным клиентским сертификатом; у телефона — узкий API-хост без админки. Внутри всё под обычным пользователем в unprivileged-контейнере.

Рутину — скрипты дедупа, разбор Takeout-активности, сверки, массовое удаление из Google — делал ИИ; решения «что удаляем, что геотегаем, где порог» оставались за мной, и перед каждой правкой я получал отчёт, что именно она затронет. Для музыки такой режим был удобством — для фотографий он обязателен: это единственные данные, которые не скачаешь заново, хотя сам архив я конечно все равно сохранил в исходном виде навсегда.

P.S. В итоге я перенес музыку домой и фото из хранилища гугла домой, а после этого получил письмо от гугла, что по новым правилам все данные бэкапов и прочего теперь будут занимать место в хранилище. Они этим должны были спровоцировать меня подписку дороже купить, но у меня в хранилище теперь занято на самой мелкой подписке меньше трети и я обдумываю, а не выкинуть ли мне подписку вовсе. Особо обидно, что они за мной следили годами, а когда надо стало мне оказалось, что все удалили без спроса.

ссылка на оригинал статьи https://habr.com/ru/articles/1060610/