init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/")
|
||||
}
|
||||
Reference in New Issue
Block a user