Files
ds-galo/openspec/changes/fix-album-cover/design.md
T
iu d7a37328f0 fix(photos): faster large-album loading and album cover lifecycle
Large albums opened slowly and flickered; the default album cover was
also lost in the media-objects refactor.

Large-album loading:
- quantize signed object URLs within a window and send Cache-Control so
  previews are reused instead of re-fetched
- publish photo.updated (not photo.created) when variants are ready, so
  clients update in place without reordering the grid
- worker concurrency + newest-first variant priority; raise default batch

Album cover:
- first uploaded photo becomes the cover when none is set
- clear the cover when the cover photo is trashed or moved out
- "set as album cover" in the photo context menu and actions panel

OpenSpec changes: fix-large-album-load, fix-album-cover
2026-10-11 14:15:59 +03:00

6.7 KiB
Raw Blame History

Design

Context

Требование «Обложка по умолчанию» существовало и работало в старом инлайн-пути генерации вариантов (processVariants), который вызывал SetCoverIfEmpty. Рефакторинг media-objects (коммит 96268ee) перенёс генерацию вариантов в фоновый worker и потерял этот вызов; сейчас SetCoverIfEmpty вызывается только из CopyBatch. Дополнительно обложка не снимается при уходе фото-обложки. См. proposal.md — Why.

Опорные точки:

  • internal/photos/service.go: Upload, Delete, MoveBatch; SetCoverIfEmpty — метод репозитория фото (store.go, repository.go, sqlite_store.go).
  • internal/photos/worker.go: с недавних пор обрабатывает новые фото первыми (created_at DESC) — отсюда решение не ставить обложку в worker'е.
  • albums.Update уже поддерживает cover_photo_id и clear_cover.
  • Frontend: albums.setCover() уже существует; контекстное меню — openMenuForPhoto в albums/[id]/+page.svelte; секция «Действия» — PhotoPanel.svelte (props: photo, role).

Goals / Non-Goals

Goals:

  • Обложкой нового альбома становится первое загруженное фото.
  • Обложка снимается, когда фото-обложка покидает альбом (удаление/перенос).
  • Пункт «Установить как обложку альбома» доступен из сетки и правой панели.

Non-Goals:

  • Backfill существующих альбомов.
  • Автоперевыбор новой обложки после снятия.
  • Изменение поведения Viewer и share-страниц.

Decisions

1. Обложка ставится при загрузке, а не в worker'е

Выбор: в Service.Upload, после успешного сохранения фото, вызвать store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID).

Почему: формулировка «первое загруженное фото». Worker обрабатывает новейшие фото первыми, поэтому установка «при обработке» дала бы обложку = последнее загруженное. Загрузка идёт последовательно по файлам запроса, поэтому первое фото детерминированно выигрывает (UPDATE ... WHERE cover_photo_id IS NULL).

Альтернативы:

  • В worker'е по готовности вариантов — thumb уже есть, но порядок обработки ломает «первое загруженное».
  • В worker'е с выбором MIN(created_at) — сложнее и не гарантирует готовность thumb выбранного фото.

Следствие: cover_thumb_url пуст до генерации thumb; список альбомов подхватит его при следующем запросе (JOIN по cover_photo_id). Реальное время смены обложки в рейле не обновляется по SSE — вне области.

2. Снятие обложки — атомарный метод репозитория

Выбор: метод ClearCoverIfMatches(ctx, albumID, photoID): UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2. Вызывается в Delete (корзина) и в MoveBatch для исходного альбома.

Почему: условный UPDATE защищает от гонки (обложку могли сменить) и не требует чтения. PermanentDelete уже покрыт FK ON DELETE SET NULL.

Альтернативы:

  • Снять безусловно — риск снести чужой выбор обложки.
  • FK при soft-delete — FK не срабатывает при мягком удалении.

3. clear_cover → NULL

Выбор: явный сброс обложки оставляет cover_photo_id = NULL (пересчёта по умолчанию нет). Соответствует решению «очищать, без backfill».

Альтернативы:

  • Пересчитать «по умолчанию» (первое фото) — тогда сброс бессмыслен (обложка мгновенно вернётся), плюс скрытый backfill.

4. Frontend: та же защита прав, что и у редактирования альбома

Выбор: пункт показывается при role === 'owner' || role === 'admin'; действие вызывает albums.setCover(albumId, photo.id) (существующий PATCH /albums/:id). Добавляется в openMenuForPhoto и в секцию «Действия» PhotoPanel; иконка wallpaper.

Почему: переиспользуется существующий API и гейт прав; PhotoPanel уже получает role. albumId берётся из photo.album_id/контекста страницы.

Risks / Trade-offs

  • [Пункт меню/панели меняет обложку, но рейл не обновляется мгновенно, если thumb ещё не готов] → допустимо; при следующем запросе обложка появится.
  • [Гонка параллельных загрузок в пустой альбом] → условный UPDATE выбирает одного победителя; «первое» при строго параллельных запросах незначительно недетерминировано.
  • [Снятие обложки при переносе может неожиданно обнулить обложку] → это и есть желаемое поведение по спеке; пользователь может назначить заново.
  • [Существующие альбомы без обложки остаются пустыми] → осознанно (no backfill).

Migration Plan

  • Миграций нет. Порядок: backend (загрузка/снятие обложки) → frontend (меню/панель).
  • Откат — возврат предыдущего бинаря/фронта; данные не деградируют.