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:
iu
2026-10-11 14:15:59 +03:00
parent 6361e48b20
commit d7a37328f0
40 changed files with 999 additions and 57 deletions
+30 -4
View File
@@ -20,33 +20,59 @@ type Signer struct {
secret []byte
baseURL string
ttl time.Duration
window time.Duration
now func() time.Time
}
// NewSigner builds a signer. secret signs the URLs (required); baseURL is the
// public base URL of the app; ttl limits URL lifetime (defaults to one hour).
func NewSigner(baseURL, secret string, ttl time.Duration) (*Signer, error) {
// public base URL of the app; ttl limits URL lifetime (defaults to one hour);
// window quantizes the expiry so the same object yields an identical URL within
// one window (defaults to ten minutes, and is capped at ttl). A non-positive
// window keeps the URL stable for its whole lifetime.
func NewSigner(baseURL, secret string, ttl, window time.Duration) (*Signer, error) {
if strings.TrimSpace(secret) == "" {
return nil, errors.New("url secret is required")
}
if ttl <= 0 {
ttl = time.Hour
}
if window <= 0 {
window = 10 * time.Minute
}
if window > ttl {
window = ttl
}
return &Signer{
secret: []byte(secret),
baseURL: strings.TrimRight(baseURL, "/"),
ttl: ttl,
window: window,
now: time.Now,
}, nil
}
// URL returns a signed app URL for the given opaque key.
// URL returns a signed app URL for the given opaque key. The expiry is
// quantized to the signing window so repeated calls within that window return
// the identical URL, letting clients reuse cached objects.
func (s *Signer) URL(key string) string {
exp := time.Now().Add(s.ttl).Unix()
exp := s.expiry()
q := url.Values{}
q.Set("e", strconv.FormatInt(exp, 10))
q.Set("s", s.sign(key, exp))
return fmt.Sprintf("%s/api/objects/%s?%s", s.baseURL, escapeKey(key), q.Encode())
}
// expiry is the quantized Unix expiry shared by all URLs issued in the current
// window: the window start plus the TTL. It is never earlier than now.
func (s *Signer) expiry() int64 {
now := s.now()
exp := now.Truncate(s.window).Add(s.ttl)
if exp.Before(now) {
exp = now.Add(s.ttl)
}
return exp.Unix()
}
// VerifyRequest checks the signature and expiry carried by an object request.
// It returns ErrSignedURLInvalid when the request is not authorized.
func (s *Signer) VerifyRequest(key string, r *http.Request) error {