← все статьи

NAS, день девятнадцатый: телефон и iCloud наполняют одну медиатеку и не встречаются дважды

Девятнадцатый день NAS. Семейную медиатеку на ящик везут двое: приложение на телефоне и сам ящик, который выкачивает старое прямо из iCloud. Эта запись — почему одного пути не хватило и как сделать, чтобы два пути не положили один снимок дважды и не затёрли друг друга.

Почему не через телефон

Сначала план был простой: приложение выгружает всё, что видит «Фото». Но у больших медиатек оригиналы лежат в iCloud, а на телефоне — только уменьшенные копии.

Чтобы выгрузить оригинал, телефон сначала качает его себе. «Фото» оставляет каждый скачанный файл кэшем и не отдаёт место, пока его не станет не хватать, а выгнать кэш приложению нечем. На медиатеке в 1,6 ТБ телефон или Mac забивается примерно за час.

У второго телефона в семье всё наоборот: место в iCloud кончилось, и с марта новые снимки лежат только на телефоне. «Выгрузить всё» дошло бы до старого и стало бы качать его на телефон с 28 ГБ свободного места.

Решение — разделить работу. Старое из iCloud забирает сам ящик, сервер к серверу. Телефон везёт только то, что лежит на нём самом: режим «Только с устройства» запрещает «Фото» сеть и пропускает снимки, которых на телефоне нет.

Но тогда один и тот же снимок часто есть у обоих: телефон его уже выгрузил, а в iCloud он тоже лежит. У двух источников одной медиатеки три способа ошибиться: положить снимок дважды, затереть чужой файл своим и скачать то, что уже лежит.

Одна раскладка на двоих

Главное решение: ящик кладёт файлы из iCloud в тот же бакет и под те же ключи, что и приложение. Ключ собирается из момента съёмки и начала хеша самого файла.

aws s3 ls s3://photos/2024/07/
2024-07-14_18-03-27_9f2c41ab.heic
2024-07-14_18-03-27_9f2c41ab.heic.json
2024-07-14_18-03-27_51d0e7c3.mov
2024-07-14_18-03-27_51d0e7c3.mov.json
2024-07-14_18-05-02_c08e3b19.heic
2024-07-14_18-05-02_c08e3b19.heic.json
Условный пример: снимок и его Live — одна секунда, разные хеши; рядом с каждым файлом — подпись .json

Источник правды — функция приложения на Rust. Ящик повторяет её на Python и сверен с ней на самых скользких местах: летнее время тех лет, когда снят кадр, и имена без расширения. Тест сверки живёт в проверках ящика.

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

Из iCloud берётся ровно то, что берёт приложение: оригинал, видео Live Photo, RAW из пары и текущая правка, каждый файл своим ключом. Сверх этого — «Скрытые» и общая медиатека: приложение их не видит, поэтому они включаются переключателями.

Как ящик узнаёт выгруженное телефоном

Хеш в ключе создаёт ловушку: чтобы посчитать ключ файла из iCloud, его надо скачать. А смысл как раз в том, чтобы не качать уже выгруженное.

Поэтому до скачивания ящик спрашивает свой индекс по тому, что iCloud сообщает сразу: есть ли в бакете файл той же секунды съёмки, с тем же расширением и ровно тем же размером в байтах. Есть — файл отмечается «уже был» и не качается вовсе.

Если совпадения нет, файл качается, и дальше решает полный ключ с хешем:

  1. Ключ свободен. Файл ложится, рядом — подпись .json* с полями приложения и пометкой source: icloud.

  2. Под ключом тот же файл. Совпал sha256* — ничего не трогаем, «уже был».

  3. Под ключом другой файл.* Конфликт в журнал, а не перезапись. Разбирает человек.

К файлу, который выгрузил телефон, ящик свою подпись не пишет: у него уже есть подпись приложения, и бакет неизменяемый.

Срок переноса из-за этого считается не по байтам, а по снимкам за последние две минуты. Пока перенос идёт по уже выгруженному, байт почти нет, и по скорости скачивания срок выходил неделями.

Цена входа в iCloud и почему ей можно доверять

У iCloud нет официального серверного доступа к фото. Ящик ходит туда библиотекой pyicloud — и получает полный доступ к сессии Apple ID, вместе с удалением.

Поэтому только чтение держится не на нашей аккуратности, а на трёх замках:

Пароль нужен один раз и нигде не остаётся: пароль, код с устройства — и дальше живёт сессия, около двух месяцев. Когда она кончится, придёт пуш «войдите заново», а перенос продолжит с того же места.

Каждый скачанный файл сверяется по размеру с тем, что заявил iCloud, а sha256 считается при приёме. Не сошлось — файл не принимается и уходит в список ошибок. Обрыв, пауза или перезапуск ящика не теряют сделанного: журнал помнит каждый файл.

Один снимок — одна плитка

Даже без дублей в бакете один кадр может состоять из файлов разного происхождения: оригинал привёз телефон, а правку или видео Live Photo — ящик из iCloud.

Лента «Фото» на ящике склеивает их в одну плитку. Внутри одного источника — по секунде съёмки и общему идентификатору, между телефоном и iCloud — по имени из «Фото» вроде IMG_1234.

То же имя ящик отдаёт приложению. Раньше Mac показывал снимок, перенесённый из iCloud, как «ещё не на NAS» — и весь день в ленте шёл дублями: он судил только по своему списку выгруженного. Теперь приложение прячет локальный снимок, если на ящике есть снимок того же вида, с тем же именем и моментом ± 2 с.

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

Скорость: напрямую из iCloud — в 20 раз быстрее

Официальным путём оригиналы из iCloud уходят медленно: приложение Apple отдаёт 4–5 МБ/с, и это в лучшем случае. Наше приложение с телефона везёт 30–35 МБ/с. А ящик, который ходит в iCloud сам, сервер к серверу, берёт 100 МБ/с и больше.

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

И даже на такой скорости из iCloud ящик качает только то, чего у него ещё нет: всё, что уже привёз телефон, пропускается без скачивания.

Что проверено, а что нет

bash platform/icloud-check.sh
── Пробный прогон и пауза
✓ пробный: 3 снимка целиком, без оборванных
✓ число потоков сменилось на ходу → 2
✓ пауза посреди закачки → paused
✓ недокачанное видео не легло в бакет
── Раскладка и подписи
✓ все файлы на ключах приложения
✓ sha256 в бакете = sha256 исходника
✓ подпись: source = icloud
── Встреча с приложением
✓ файл приложения не качался (дата+размер), подпись приложения не тронута
✓ конфликт записан, а не перезаписан → conflict
✓ неполный файл не принят → iCloud заявил 30630 байт, пришло 15315
── Повтор
✓ повтор: объектов было 17, стало 17
✓ повтор качал только то, что не легло
── Только чтение
✓ методы записи библиотеки — заглушки: 12 из 12
✓ удалить снимок нельзя → PhotoAsset.delete: перенос из iCloud только читает
✓ контроль: запрос на запись мимо замка до сервера доходит
✓ запросы на запись в iCloud остановлены до сети: 4 из 4, дошло до сервера — 0
ИТОГ: всё сошлось
Проверка на стенде, 30 октября: 27 проверок, все зелёные; здесь — главные

Сколько снимков перенёс живой прогон и сколько из них оказались «уже были», здесь не приводим: эти цифры мы не снимали.

Что осталось в панели

  • «Хранилище → Перенос из iCloud»: вход паролем и кодом, пробный прогон, пауза, ползунок потоков, срок по ходу в снимках.
  • Сверка «в iCloud — на ящике» и списки ошибок и конфликтов.
  • История синхронизаций: прогоны переноса и сеансы телефонов в одном списке.
  • Лента «Фото», где снимок из двух источников — одна плитка.
  • В приложении — режим «Только с устройства».

Своё железо — тоже система
Начните с брифа: четыре вопроса и честный ответ, подходим ли мы друг другу.
Пройти бриф