diff --git a/Dockerfile b/Dockerfile index c90f9d0..37ac96e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,15 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/rememb FROM alpine:3.21 -RUN apk add --no-cache imagemagick tzdata +# Базовый пакет imagemagick идёт без кодеков: без них magick не читает и не +# пишет JPEG (а с телефонов приходят ещё HEIC и WebP). +RUN apk add --no-cache \ + imagemagick \ + imagemagick-jpeg \ + imagemagick-heic \ + imagemagick-webp \ + imagemagick-tiff \ + tzdata COPY --from=build /out/remembos /remembos diff --git a/internal/media/convert.go b/internal/media/convert.go index 175b589..90f5ad2 100644 --- a/internal/media/convert.go +++ b/internal/media/convert.go @@ -84,9 +84,19 @@ func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) cmd := exec.CommandContext(ctx, "magick", cmdArgs...) cmd.Stdin = bytes.NewReader(data) + // Без stderr от magick ошибка выглядит как голое "exit status 1" + // и не подсказывает, чего не хватает (обычно — кодека). + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("magick: %w", err) + return nil, fmt.Errorf("magick: %w: %s", err, strings.TrimSpace(stderr.String())) + } + + // Без кодека magick иногда завершается успешно, но не пишет ничего. + if len(out) == 0 { + return nil, fmt.Errorf("magick produced empty output: %s", strings.TrimSpace(stderr.String())) } return out, nil diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index b1c94fe..d929f9b 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -267,9 +267,13 @@ func (b *Bot) downloadAndCompressImages(ctx context.Context, attachments []memos continue } + srcBytes := len(data) + data, filename, err := media.CompressImage(ctx, data, att.Filename) if err != nil { - b.logger.Warn("failed to compress image, skipping", "name", att.Name, "error", err) + b.logger.Warn("failed to compress image, skipping", + "name", att.Name, "filename", att.Filename, "mime", att.Type, + "src_bytes", srcBytes, "error", err) skipped = true continue } diff --git a/internal/web/images.go b/internal/web/images.go index caa7da6..efe96dc 100644 --- a/internal/web/images.go +++ b/internal/web/images.go @@ -2,11 +2,13 @@ package web import ( "context" + "fmt" "net/http" "net/url" "strconv" "strings" "sync" + "time" "git.vakhrushev.me/av/remembos/internal/media" "git.vakhrushev.me/av/remembos/internal/memos" @@ -21,6 +23,9 @@ const imagePathPrefix = "/image/" // чтобы не пережимать их заново на каждую перезагрузку страницы. const imageCacheSize = 20 +// imageCacheMaxBytes — верхняя граница размера одной записи кэша. +const imageCacheMaxBytes = 4 * 1024 * 1024 + // attachmentUID returns the identifier used in image URLs ("attachments/{uid}" → "{uid}"). func attachmentUID(att memos.Attachment) string { return strings.TrimPrefix(att.Name, "attachments/") @@ -42,7 +47,10 @@ func (h *Handler) handleImage(w http.ResponseWriter, r *http.Request) { data, contentType, err := h.imageBytes(r.Context(), att) if err != nil { - h.logger.Error("failed to prepare image", "name", att.Name, "error", err) + h.logger.Error("failed to prepare image", + "name", att.Name, "filename", att.Filename, "mime", att.Type, + "declared_bytes", att.Size, "external", att.ExternalLink != "", + "error", err) http.Error(w, "не удалось загрузить изображение", http.StatusBadGateway) return } @@ -76,26 +84,55 @@ func (h *Handler) findAttachment(ctx context.Context, uid string) (memos.Attachm return att, true } } + + // Обычно это устаревшая ссылка: страницу открыли до того, как воспоминание сменилось. + h.logger.Info("image not found in current memory", "uid", uid, "memo", mem.Memo.Name) return memos.Attachment{}, false } // imageBytes downloads and resizes the attachment, caching the result. func (h *Handler) imageBytes(ctx context.Context, att memos.Attachment) ([]byte, string, error) { if img, ok := h.images.get(att.Name); ok { + h.logger.Debug("image served from cache", + "name", att.Name, "mime", img.contentType, "bytes", len(img.data)) return img.data, img.contentType, nil } + started := time.Now() + raw, err := h.client.DownloadAttachment(ctx, att) if err != nil { - return nil, "", err + return nil, "", fmt.Errorf("download %s (%s): %w", att.Filename, att.Type, err) } + downloaded := time.Since(started) + resizeStart := time.Now() data, contentType, err := media.ResizeForWeb(ctx, raw, att.Type) if err != nil { - return nil, "", err + // Отдать картинку неужатой лучше, чем не отдать вовсе. + h.logger.Warn("failed to resize image, serving original", + "name", att.Name, "filename", att.Filename, "mime", att.Type, + "src_bytes", len(raw), "error", err) + data, contentType = raw, att.Type + } + if contentType == "" { + contentType = "application/octet-stream" } - h.images.put(att.Name, cachedImage{data: data, contentType: contentType}) + // Оригиналы бывают на десятки мегабайт — такие в кэш не кладём. + cached := len(data) <= imageCacheMaxBytes + if cached { + h.images.put(att.Name, cachedImage{data: data, contentType: contentType}) + } + + h.logger.Info("image prepared", + "name", att.Name, "filename", att.Filename, + "src_mime", att.Type, "src_bytes", len(raw), + "out_mime", contentType, "out_bytes", len(data), + "download", downloaded.Round(time.Millisecond).String(), + "resize", time.Since(resizeStart).Round(time.Millisecond).String(), + "cached", cached) + return data, contentType, nil }