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

99 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (меню/панель).
- Откат — возврат предыдущего бинаря/фронта; данные не деградируют.