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
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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 (меню/панель).
|
||||
- Откат — возврат предыдущего бинаря/фронта; данные не деградируют.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Автобложка «первое фото» была специфицирована, но потерялась при рефакторинге media-objects:
|
||||
при загрузке обложка больше не назначается, поэтому большинство альбомов (в т.ч. семенные)
|
||||
остаются без обложки. Дополнительно нужно довести жизненный цикл обложки: снимать её, когда
|
||||
фото-обложка покидает альбом, и сделать явный сброс предсказуемым.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Обложка по умолчанию при загрузке.** Если у альбома нет обложки, обложкой становится
|
||||
первое загруженное фото; последующие фото её не меняют.
|
||||
- **Снятие обложки при уходе фото-обложки.** Помещение фото-обложки в корзину или перенос
|
||||
её в другой альбом очищает обложку (без перевыбора).
|
||||
- **Явный сброс обложки** (`clear_cover`) обнуляет обложку и не пересчитывает её.
|
||||
- **Frontend.** Пункт «Установить как обложку альбома» в контекстном меню фото сетки и в
|
||||
разделе «Действия» правой панели; доступен только владельцу и админу.
|
||||
- **Без backfill** существующих альбомов без обложки.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- нет -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `photos`: обложка по умолчанию назначается при загрузке (первое фото) и снимается, когда
|
||||
фото-обложка покидает альбом.
|
||||
- `albums`: сброс обложки делает её не заданной (без возврата к значению по умолчанию).
|
||||
|
||||
## Impact
|
||||
|
||||
- Backend: `internal/photos/service.go` (`Upload`, `Delete`, `MoveBatch`),
|
||||
`internal/photos/{store,repository,sqlite_store}.go` (метод снятия обложки).
|
||||
- Frontend: контекстное меню фото в сетке (`albums/[id]/+page.svelte`), раздел «Действия»
|
||||
в `PhotoPanel.svelte`; используется существующий `PATCH /albums/:id` через `albums.setCover`.
|
||||
- Данные/миграции: не требуются; backfill не выполняется.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Выбор обложки
|
||||
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
|
||||
обложку; после сброса обложка SHALL становиться не заданной (без возврата к значению по
|
||||
умолчанию).
|
||||
|
||||
#### Scenario: Назначение обложки
|
||||
- **WHEN** владелец выбирает фото альбома как обложку
|
||||
- **THEN** система сохраняет выбор и отражает его в списке и карточках
|
||||
|
||||
#### Scenario: Сброс обложки
|
||||
- **WHEN** админ сбрасывает обложку
|
||||
- **THEN** система снимает обложку, и альбом отображается без обложки, пока обложку не назначат снова
|
||||
|
||||
#### Scenario: Нет прав на смену обложки
|
||||
- **WHEN** участник без права редактирования пытается сменить обложку
|
||||
- **THEN** система отклоняет запрос
|
||||
@@ -0,0 +1,24 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Обложка по умолчанию
|
||||
При загрузке фото система SHALL назначать его обложкой альбома, если обложка ещё не задана,
|
||||
то есть обложкой становится первое загруженное фото. Когда фото, выбранное обложкой, покидает
|
||||
альбом (помещено в корзину или перенесено в другой альбом), система SHALL снять обложку.
|
||||
|
||||
#### Scenario: Первое фото
|
||||
- **WHEN** фото загружено, и у альбома нет обложки
|
||||
- **THEN** система назначает это фото обложкой альбома
|
||||
|
||||
#### Scenario: Обложка уже задана
|
||||
- **WHEN** фото загружено, а обложка уже задана
|
||||
- **THEN** система не меняет обложку
|
||||
|
||||
#### Scenario: Фото-обложка в корзине
|
||||
- **WHEN** фото, назначенное обложкой, помещается в корзину
|
||||
- **THEN** система снимает обложку, и альбом отображается без обложки
|
||||
|
||||
#### Scenario: Фото-обложка перенесено
|
||||
- **WHEN** фото, назначенное обложкой, переносится в другой альбом
|
||||
- **THEN** система снимает обложку исходного альбома
|
||||
@@ -0,0 +1,21 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Автобложка при загрузке
|
||||
|
||||
- [x] 1.1 В `backend/internal/photos/service.go` (`Upload`) вызывать `store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)` после успешного сохранения фото; проверить `cd backend && go test ./internal/e2e/` — тест загружает два фото в новый альбом и проверяет, что обложкой стало первое
|
||||
- [x] 1.2 Добавить в тот же тест проверку сценария «обложка уже задана»: загрузка второго фото не меняет уже назначенную обложку
|
||||
|
||||
## 2. Снятие обложки при уходе фото-обложки
|
||||
|
||||
- [x] 2.1 Добавить `ClearCoverIfMatches(ctx, albumID, photoID)` в `backend/internal/photos/repository.go`, `store.go` и `sqlite_store.go` (`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`); проверить `cd backend && go test ./internal/repo/contract/` — снятие срабатывает только при совпадении обложки
|
||||
- [x] 2.2 Вызывать `ClearCoverIfMatches` в `Delete` (корзина) и `MoveBatch` (исходный альбом) в `service.go`; проверить тестом, что удаление и перенос фото-обложки снимают обложку
|
||||
|
||||
## 3. Пункт «Установить как обложку альбома»
|
||||
|
||||
- [x] 3.1 Добавить пункт в контекстное меню фото сетки (`frontend/src/routes/(app)/albums/[id]/+page.svelte`, `openMenuForPhoto`) с иконкой `wallpaper` и вызовом `albums.setCover(albumId, photo.id)`, видимый только owner/admin; проверить `cd frontend && npm run check`
|
||||
- [x] 3.2 Добавить тот же пункт в секцию «Действия» компонента `frontend/src/lib/components/PhotoPanel.svelte`; проверить `npm run check` и наблюдаемо: пункт показывается только владельцу/админу
|
||||
|
||||
## 4. Интеграционная проверка
|
||||
|
||||
- [x] 4.1 `cd backend && go test ./...` и `cd frontend && npm run check` — оба зелёные
|
||||
- [x] 4.2 Собрать (`make build`), загрузить фото в новый альбом (обложка = первое фото), затем удалить фото-обложку (обложка пуста) и перенести фото-обложку (обложка пуста); сбросить обложку вручную через панель — остаётся пустой
|
||||
Reference in New Issue
Block a user