This commit is contained in:
2026-08-22 02:59:16 +02:00
commit 6a5bb1d699
100 changed files with 17409 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
package media
import (
"context"
"encoding/json"
"fmt"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
const (
maxUploadSize = int64(10 << 30)
uploadURLDuration = 24 * time.Hour
)
type ProcessorQueue interface {
Enqueue(uuid.UUID)
}
type Handler struct {
repository *Repository
storage storage.Storage
processor ProcessorQueue
auth *auth.Service
}
func NewHandler(repository *Repository, objectStorage storage.Storage, processor ProcessorQueue, authService *auth.Service) *Handler {
return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
mux.Handle("GET /api/galleries/{id}/media", require(http.HandlerFunc(h.List)))
mux.Handle("POST /api/galleries/{id}/uploads", require(http.HandlerFunc(h.CreateUpload)))
mux.Handle("POST /api/uploads/{id}/complete", require(http.HandlerFunc(h.CompleteUpload)))
mux.Handle("PATCH /api/media/{id}", require(http.HandlerFunc(h.Update)))
mux.Handle("POST /api/media/{id}/download", require(http.HandlerFunc(h.Download)))
mux.Handle("DELETE /api/uploads/{id}", require(http.HandlerFunc(h.Delete)))
mux.Handle("DELETE /api/media/{id}", require(http.HandlerFunc(h.Delete)))
}
type uploadRequest struct {
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
FileSize int64 `json:"fileSize"`
}
type updateRequest struct {
SortOrder *int `json:"sortOrder"`
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
galleryID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid gallery id")
return
}
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
if err != nil || !belongs {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
items, err := h.repository.ListByGallery(r.Context(), galleryID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load media")
return
}
views := make([]Public, 0, len(items))
for _, item := range items {
view, err := h.view(r.Context(), item, true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
views = append(views, view)
}
writeJSON(w, http.StatusOK, map[string]any{"media": views})
}
func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
galleryID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid gallery id")
return
}
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
if err != nil || !belongs {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
var request uploadRequest
if !decodeJSON(w, r, &request) {
return
}
filename := safeFilename(request.Filename)
mimeType := strings.ToLower(strings.TrimSpace(request.MimeType))
if mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(filename))
}
if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) {
writeError(w, http.StatusBadRequest, "unsupported media file")
return
}
if request.FileSize <= 0 || request.FileSize > maxUploadSize {
writeError(w, http.StatusBadRequest, "file size must be between 1 byte and 10 GB")
return
}
mediaID := uuid.New()
storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename)
item, err := h.repository.Create(r.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create upload")
return
}
uploadURL, err := h.storage.CreateUploadURL(r.Context(), storageKey, mimeType, uploadURLDuration)
if err != nil {
_, _ = h.repository.Delete(r.Context(), user.ID, mediaID)
writeError(w, http.StatusInternalServerError, "could not create upload URL")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"uploadId": mediaID.String(),
"uploadUrl": uploadURL,
"media": publicFromRecord(item),
})
}
func (h *Handler) CompleteUpload(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
if err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
info, err := h.storage.Stat(r.Context(), item.StorageKey)
if err != nil {
writeError(w, http.StatusBadRequest, "uploaded object is not available yet")
return
}
item, err = h.repository.Complete(r.Context(), user.ID, mediaID, info.Size)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not complete upload")
return
}
h.processor.Enqueue(item.ID)
writeJSON(w, http.StatusOK, map[string]any{"media": publicFromRecord(item)})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
var request updateRequest
if !decodeJSON(w, r, &request) {
return
}
if request.SortOrder == nil || *request.SortOrder < 0 {
writeError(w, http.StatusBadRequest, "sort order must be zero or greater")
return
}
if err := h.repository.UpdateSortOrder(r.Context(), user.ID, mediaID, *request.SortOrder); err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
item, _ := h.repository.GetForUser(r.Context(), user.ID, mediaID)
view, err := h.view(r.Context(), item, true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URL")
return
}
writeJSON(w, http.StatusOK, map[string]any{"media": view})
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.Delete(r.Context(), user.ID, mediaID)
if err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} {
if key != "" && key != item.ExternalURL {
_ = h.storage.Delete(r.Context(), key)
}
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
if err != nil || item.ProcessingStatus != StatusReady {
writeError(w, http.StatusNotFound, "media not found")
return
}
url := item.ExternalURL
if url == "" {
url, err = h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download")
return
}
}
_ = h.repository.RecordDownload(r.Context(), item.GalleryID, &item.ID, user.ID.String())
writeJSON(w, http.StatusOK, map[string]string{"url": url})
}
func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) {
view := publicFromRecord(item)
if item.ProcessingStatus != StatusReady {
return view, nil
}
if item.ExternalURL != "" {
view.ThumbnailURL = item.ExternalURL
view.PreviewURL = item.ExternalURL
if includeOriginal {
view.OriginalURL = item.ExternalURL
}
return view, nil
}
previewKey := item.PreviewKey
if previewKey == "" {
previewKey = item.StorageKey
}
thumbnailKey := item.ThumbnailKey
if thumbnailKey == "" {
thumbnailKey = previewKey
}
var err error
view.ThumbnailURL, err = h.storage.CreateDownloadURL(ctx, thumbnailKey, time.Hour)
if err != nil {
return Public{}, err
}
view.PreviewURL, err = h.storage.CreateDownloadURL(ctx, previewKey, time.Hour)
if err != nil {
return Public{}, err
}
if includeOriginal {
view.OriginalURL, err = h.storage.CreateDownloadURL(ctx, item.StorageKey, time.Hour)
if err != nil {
return Public{}, err
}
}
return view, nil
}
func publicFromRecord(item Record) Public {
return Public{
ID: item.ID,
OriginalFilename: item.OriginalFilename,
MimeType: item.MimeType,
FileSize: item.FileSize,
ProcessingStatus: item.ProcessingStatus,
Width: item.Width,
Height: item.Height,
DurationSeconds: item.DurationSeconds,
SortOrder: item.SortOrder,
}
}
func allowedMimeType(value string) bool {
switch value {
case "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", "video/mp4", "video/quicktime", "video/webm":
return true
default:
return false
}
}
func safeFilename(value string) string {
value = strings.ReplaceAll(value, "\\", "/")
value = filepath.Base(value)
if value == "." || value == ".." {
return ""
}
return strings.TrimSpace(value)
}
func parseID(value string) (uuid.UUID, error) {
return uuid.Parse(value)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return false
}
return true
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+46
View File
@@ -0,0 +1,46 @@
package media
import "github.com/google/uuid"
const (
StatusUploading = "UPLOADING"
StatusProcessing = "PROCESSING"
StatusReady = "READY"
StatusFailed = "FAILED"
)
type Record struct {
ID uuid.UUID
GalleryID uuid.UUID
OriginalFilename string
MimeType string
FileSize int64
StorageKey string
ExternalURL string
ThumbnailKey string
PreviewKey string
ProcessingStatus string
ProcessingError string
Width int
Height int
DurationSeconds float64
SortOrder int
CreatedAt string
UpdatedAt string
}
type Public struct {
ID uuid.UUID `json:"id"`
OriginalFilename string `json:"originalFilename"`
MimeType string `json:"mimeType"`
FileSize int64 `json:"fileSize"`
ProcessingStatus string `json:"processingStatus"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
SortOrder int `json:"sortOrder"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
OriginalURL string `json:"originalUrl,omitempty"`
Favorited bool `json:"favorited"`
}
+154
View File
@@ -0,0 +1,154 @@
package media
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
_ "image/png"
"io"
"sync"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Processor struct {
repository *Repository
storage storage.Storage
jobs chan uuid.UUID
stop chan struct{}
waitGroup sync.WaitGroup
}
func NewProcessor(repository *Repository, objectStorage storage.Storage, workers int) *Processor {
if workers < 1 {
workers = 1
}
processor := &Processor{
repository: repository,
storage: objectStorage,
jobs: make(chan uuid.UUID, 256),
stop: make(chan struct{}),
}
for index := 0; index < workers; index++ {
processor.waitGroup.Add(1)
go processor.worker()
}
return processor
}
func (p *Processor) Enqueue(mediaID uuid.UUID) {
select {
case p.jobs <- mediaID:
case <-p.stop:
}
}
func (p *Processor) Close() {
close(p.stop)
p.waitGroup.Wait()
}
func (p *Processor) worker() {
defer p.waitGroup.Done()
for {
select {
case mediaID := <-p.jobs:
p.process(mediaID)
case <-p.stop:
return
}
}
}
func (p *Processor) process(mediaID uuid.UUID) {
ctx := context.Background()
item, err := p.repository.GetByID(ctx, mediaID)
if err != nil {
return
}
if item.ExternalURL != "" || !IsImage(item) {
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height)
return
}
object, err := p.storage.Get(ctx, item.StorageKey)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
defer object.Close()
decoded, _, err := image.Decode(io.LimitReader(object, 100<<20))
if err != nil {
// Formats without a stdlib decoder, such as HEIC, remain usable through
// the original object until a dedicated processing service is added.
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, 0, 0)
return
}
width := decoded.Bounds().Dx()
height := decoded.Bounds().Dy()
previewKey := variantKey(item, "preview.jpg")
thumbnailKey := variantKey(item, "thumbnail.jpg")
preview, err := encodeJPEG(resize(decoded, 2400), 88)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
thumbnail, err := encodeJPEG(resize(decoded, 640), 84)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/jpeg"); err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.storage.Put(ctx, thumbnailKey, bytes.NewReader(thumbnail), int64(len(thumbnail)), "image/jpeg"); err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.repository.MarkReady(ctx, mediaID, previewKey, thumbnailKey, width, height); err != nil {
return
}
}
func variantKey(item Record, filename string) string {
return fmt.Sprintf("galleries/%s/%s/%s", item.GalleryID, item.ID, filename)
}
func resize(source image.Image, maxSide int) image.Image {
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxSide && height <= maxSide {
return source
}
scale := float64(maxSide) / float64(width)
if height > width {
scale = float64(maxSide) / float64(height)
}
newWidth := int(float64(width) * scale)
newHeight := int(float64(height) * scale)
destination := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
for y := 0; y < newHeight; y++ {
for x := 0; x < newWidth; x++ {
sourceX := bounds.Min.X + x*width/newWidth
sourceY := bounds.Min.Y + y*height/newHeight
destination.Set(x, y, source.At(sourceX, sourceY))
}
}
return destination
}
func encodeJPEG(source image.Image, quality int) ([]byte, error) {
var output bytes.Buffer
if err := jpeg.Encode(&output, source, &jpeg.Options{Quality: quality}); err != nil {
return nil, fmt.Errorf("encode preview: %w", err)
}
return output.Bytes(), nil
}
+225
View File
@@ -0,0 +1,225 @@
package media
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("media not found")
func (r *Repository) Create(ctx context.Context, galleryID, id uuid.UUID, filename, mimeType string, fileSize int64, storageKey string) (Record, error) {
_, err := r.db.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE((SELECT MAX(sort_order) + 1 FROM media WHERE gallery_id = $2), 0))
`, id, galleryID, filename, mimeType, fileSize, storageKey, StatusUploading)
if err != nil {
return Record{}, fmt.Errorf("create media: %w", err)
}
return r.GetByID(ctx, id)
}
func (r *Repository) GalleryBelongsToUser(ctx context.Context, galleryID, userID uuid.UUID) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM galleries WHERE id = $1 AND user_id = $2 AND status <> 'archived')
`, galleryID, userID).Scan(&exists)
return exists, err
}
func (r *Repository) GetByID(ctx context.Context, id uuid.UUID) (Record, error) {
return r.get(ctx, `
SELECT id, gallery_id, original_filename, mime_type, file_size, storage_key,
external_url, thumbnail_key, preview_key, processing_status, processing_error,
width, height, duration_seconds, sort_order, created_at, updated_at
FROM media
WHERE id = $1
`, id)
}
func (r *Repository) GetForUser(ctx context.Context, userID, mediaID uuid.UUID) (Record, error) {
return r.get(ctx, `
SELECT m.id, m.gallery_id, m.original_filename, m.mime_type, m.file_size, m.storage_key,
m.external_url, m.thumbnail_key, m.preview_key, m.processing_status, m.processing_error,
m.width, m.height, m.duration_seconds, m.sort_order, m.created_at, m.updated_at
FROM media m
JOIN galleries g ON g.id = m.gallery_id
WHERE m.id = $1 AND g.user_id = $2
`, mediaID, userID)
}
func (r *Repository) ListByGallery(ctx context.Context, galleryID uuid.UUID) ([]Record, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, gallery_id, original_filename, mime_type, file_size, storage_key,
external_url, thumbnail_key, preview_key, processing_status, processing_error,
width, height, duration_seconds, sort_order, created_at, updated_at
FROM media
WHERE gallery_id = $1
ORDER BY sort_order ASC, id ASC
`, galleryID)
if err != nil {
return nil, fmt.Errorf("list media: %w", err)
}
defer rows.Close()
result := make([]Record, 0)
for rows.Next() {
item, err := scanMedia(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate media: %w", err)
}
return result, nil
}
func (r *Repository) Complete(ctx context.Context, userID, mediaID uuid.UUID, fileSize int64) (Record, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE media
SET file_size = $1, processing_status = $2, processing_error = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = $3 AND gallery_id IN (SELECT id FROM galleries WHERE user_id = $4)
`, fileSize, StatusProcessing, mediaID, userID)
if err != nil {
return Record{}, fmt.Errorf("complete media upload: %w", err)
}
if count, _ := result.RowsAffected(); count == 0 {
return Record{}, ErrNotFound
}
return r.GetByID(ctx, mediaID)
}
func (r *Repository) MarkReady(ctx context.Context, mediaID uuid.UUID, previewKey, thumbnailKey string, width, height int) error {
_, err := r.db.ExecContext(ctx, `
UPDATE media
SET processing_status = $1, processing_error = NULL, preview_key = $2, thumbnail_key = $3,
width = $4, height = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
`, StatusReady, previewKey, thumbnailKey, width, height, mediaID)
return err
}
func (r *Repository) MarkFailed(ctx context.Context, mediaID uuid.UUID, message string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE media
SET processing_status = $1, processing_error = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusFailed, message, mediaID)
return err
}
func (r *Repository) UpdateSortOrder(ctx context.Context, userID, mediaID uuid.UUID, sortOrder int) error {
result, err := r.db.ExecContext(ctx, `
UPDATE media
SET sort_order = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND gallery_id IN (SELECT id FROM galleries WHERE user_id = $3)
`, sortOrder, mediaID, userID)
if err != nil {
return fmt.Errorf("update media order: %w", err)
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
return nil
}
func (r *Repository) Delete(ctx context.Context, userID, mediaID uuid.UUID) (Record, error) {
item, err := r.GetForUser(ctx, userID, mediaID)
if err != nil {
return Record{}, err
}
if _, err := r.db.ExecContext(ctx, `DELETE FROM media WHERE id = $1`, mediaID); err != nil {
return Record{}, fmt.Errorf("delete media: %w", err)
}
return item, nil
}
func (r *Repository) IsFavorited(ctx context.Context, galleryID, mediaID uuid.UUID, visitorID string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM favorites WHERE gallery_id = $1 AND media_id = $2 AND visitor_id = $3)
`, galleryID, mediaID, visitorID).Scan(&exists)
return exists, err
}
func (r *Repository) SetFavorite(ctx context.Context, galleryID, mediaID uuid.UUID, visitorID string, favorited bool) error {
if favorited {
_, err := r.db.ExecContext(ctx, `
INSERT INTO favorites (id, gallery_id, media_id, visitor_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (gallery_id, media_id, visitor_id) DO NOTHING
`, uuid.New(), galleryID, mediaID, visitorID)
return err
}
_, err := r.db.ExecContext(ctx, `DELETE FROM favorites WHERE gallery_id = $1 AND media_id = $2 AND visitor_id = $3`, galleryID, mediaID, visitorID)
return err
}
func (r *Repository) RecordDownload(ctx context.Context, galleryID uuid.UUID, mediaID *uuid.UUID, visitorID string) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO downloads (id, gallery_id, media_id, visitor_id)
VALUES ($1, $2, $3, $4)
`, uuid.New(), galleryID, mediaID, visitorID)
return err
}
type rowScanner interface {
Scan(...any) error
}
func (r *Repository) get(ctx context.Context, query string, args ...any) (Record, error) {
return scanMedia(r.db.QueryRowContext(ctx, query, args...))
}
func scanMedia(row rowScanner) (Record, error) {
var (
item Record
externalURL, thumbnailKey, previewKey, processingError sql.NullString
width, height sql.NullInt64
duration sql.NullFloat64
createdAt, updatedAt sql.NullString
)
err := row.Scan(
&item.ID, &item.GalleryID, &item.OriginalFilename, &item.MimeType, &item.FileSize,
&item.StorageKey, &externalURL, &thumbnailKey, &previewKey, &item.ProcessingStatus,
&processingError, &width, &height, &duration, &item.SortOrder, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, fmt.Errorf("scan media: %w", err)
}
item.ExternalURL = externalURL.String
item.ThumbnailKey = thumbnailKey.String
item.PreviewKey = previewKey.String
item.ProcessingError = processingError.String
item.Width = int(width.Int64)
item.Height = int(height.Int64)
item.DurationSeconds = duration.Float64
item.CreatedAt = createdAt.String
item.UpdatedAt = updatedAt.String
return item, nil
}
func IsImage(item Record) bool {
return strings.HasPrefix(strings.ToLower(item.MimeType), "image/")
}
func IsVideo(item Record) bool {
return strings.HasPrefix(strings.ToLower(item.MimeType), "video/")
}