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/") }