Clone переносил все привязки photo_objects, включая роли edit*, хотя правка принадлежит фото, чьи правки её породили. Теперь копия делит только обычные варианты и показывается в исходном виде. - EditRoles() и IsEditRole() в edit_render.go — единый список ролей; - фильтр edit-ролей в Store.Clone и SQLiteStore.Clone; - миграции 000016 (Postgres) и 000008 (SQLite) убирают привязки у копий, созданных до фикса; - контрактная проверка копирования, сервисные и e2e-тесты.
561 lines
18 KiB
Go
561 lines
18 KiB
Go
package photos
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
|
|
"galo/internal/auth"
|
|
"galo/internal/models"
|
|
"galo/internal/sqliteutil"
|
|
)
|
|
|
|
// SQLiteStore is the SQLite implementation of Repository.
|
|
type SQLiteStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
|
|
|
|
const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
|
COALESCE(o_o.storage_key, ''),
|
|
COALESCE(o_t.storage_key, ''),
|
|
COALESCE(o_m.storage_key, ''),
|
|
COALESCE(o_p.storage_key, ''),
|
|
COALESCE(o_pb.storage_key, ''),
|
|
COALESCE(o_e.storage_key, ''),
|
|
COALESCE(o_et.storage_key, ''),
|
|
COALESCE(o_em.storage_key, ''),
|
|
COALESCE(o_ep.storage_key, ''),
|
|
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''), COALESCE(o_o.checksum, ''),
|
|
p.kind, COALESCE(p.duration_ms, 0),
|
|
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
|
u.id, u.name, COALESCE(u.avatar_url, ''),
|
|
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
|
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
|
|
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
|
|
COALESCE(p.exif, '{}'), p.deleted_at,
|
|
COALESCE(p.edit_params, '{}'),
|
|
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name))
|
|
FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
|
WHERE pt.photo_id = p.id AND t.user_id = $2
|
|
ORDER BY lower(t.name)) t), '[]')`
|
|
|
|
const sqlitePhotoJoins = `
|
|
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
|
|
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
|
|
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
|
|
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
|
|
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
|
|
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
|
|
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
|
LEFT JOIN objects o_p ON o_p.id = po_p.object_id
|
|
LEFT JOIN photo_objects po_pb ON po_pb.photo_id = p.id AND po_pb.role = 'playback'
|
|
LEFT JOIN objects o_pb ON o_pb.id = po_pb.object_id
|
|
LEFT JOIN photo_objects po_e ON po_e.photo_id = p.id AND po_e.role = 'edit'
|
|
LEFT JOIN objects o_e ON o_e.id = po_e.object_id
|
|
LEFT JOIN photo_objects po_et ON po_et.photo_id = p.id AND po_et.role = 'edit_thumb'
|
|
LEFT JOIN objects o_et ON o_et.id = po_et.object_id
|
|
LEFT JOIN photo_objects po_em ON po_em.photo_id = p.id AND po_em.role = 'edit_medium'
|
|
LEFT JOIN objects o_em ON o_em.id = po_em.object_id
|
|
LEFT JOIN photo_objects po_ep ON po_ep.photo_id = p.id AND po_ep.role = 'edit_preview'
|
|
LEFT JOIN objects o_ep ON o_ep.id = po_ep.object_id`
|
|
|
|
func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
|
var p models.Photo
|
|
var exifText, tagsText, editText, createdAt string
|
|
var takenAt, deletedAt sql.NullString
|
|
var lat, long sql.NullFloat64
|
|
var liked int
|
|
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
|
&p.EditKey, &p.EditThumbKey, &p.EditMediumKey, &p.EditPreviewKey,
|
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
|
&p.Kind, &p.DurationMS,
|
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
|
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText); err != nil {
|
|
return nil, err
|
|
}
|
|
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
|
if lat.Valid {
|
|
p.Latitude = &lat.Float64
|
|
}
|
|
if long.Valid {
|
|
p.Longitude = &long.Float64
|
|
}
|
|
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
|
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
|
|
p.LikedByMe = liked != 0
|
|
setExif(&p, exifText)
|
|
setEditParams(&p, editText)
|
|
setTags(&p, tagsText)
|
|
return &p, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error {
|
|
exifJSON, err := json.Marshal(p.EXIF)
|
|
if err != nil || len(exifJSON) == 0 {
|
|
exifJSON = []byte("{}")
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
obj.ID, obj.StorageKey, obj.MimeType, obj.Size, obj.Width, obj.Height, obj.Checksum, ObjectPending); err != nil {
|
|
return err
|
|
}
|
|
var createdAt string
|
|
if err := tx.QueryRowContext(ctx,
|
|
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, kind, duration_ms)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
RETURNING created_at`,
|
|
p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename,
|
|
nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON),
|
|
mediaKind(p.Kind), p.DurationMS,
|
|
).Scan(&createdAt); err != nil {
|
|
return err
|
|
}
|
|
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)`,
|
|
p.ID, obj.ID, RoleOriginal); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO variant_jobs (photo_id, kind) VALUES ($1, $2) ON CONFLICT (photo_id) DO NOTHING`,
|
|
p.ID, mediaKind(p.Kind)); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SQLiteStore) DeleteUpload(ctx context.Context, photoID, objectID string) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT `+sqlitePhotoColumns+`
|
|
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
|
|
WHERE p.id = $1`, id, viewerID)
|
|
p, err := scanSQLitePhoto(row.Scan)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
|
|
if in.Limit <= 0 || in.Limit > 100 {
|
|
in.Limit = 50
|
|
}
|
|
var since any
|
|
if in.Since != nil {
|
|
since = sqliteutil.FormatTime(*in.Since)
|
|
}
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT `+sqlitePhotoColumns+`
|
|
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
|
|
WHERE p.album_id = $1
|
|
AND p.deleted_at IS NULL
|
|
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
|
AND ($4 IS NULL OR p.created_at > $4)
|
|
ORDER BY p.created_at DESC, p.id DESC
|
|
LIMIT $5`,
|
|
in.AlbumID, in.Viewer, in.Cursor, since, in.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []models.Photo{}
|
|
for rows.Next() {
|
|
p, err := scanSQLitePhoto(rows.Scan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) SetDeleted(ctx context.Context, id string, deleted bool) error {
|
|
var res sql.Result
|
|
var err error
|
|
if deleted {
|
|
res, err = s.db.ExecContext(ctx,
|
|
`UPDATE photos SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`,
|
|
id, sqliteutil.FormatTime(nowUTC()))
|
|
} else {
|
|
res, err = s.db.ExecContext(ctx, `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) UpdateAlbum(ctx context.Context, id, albumID string) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
|
|
exifJSON, err := json.Marshal(src.EXIF)
|
|
if err != nil || len(exifJSON) == 0 {
|
|
exifJSON = []byte("{}")
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, kind, duration_ms)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
id, nullIfEmpty(albumID), uploaderID, src.OriginalFilename,
|
|
nullTime(src.TakenAt), nullFloat(src.Latitude), nullFloat(src.Longitude), src.Caption, string(exifJSON),
|
|
mediaKind(src.Kind), src.DurationMS); err != nil {
|
|
return err
|
|
}
|
|
// The edited version belongs to the photo whose edits produced it, so the
|
|
// copy shares the base variants only.
|
|
roles := EditRoles()
|
|
args := []any{id, src.ID}
|
|
for _, role := range roles {
|
|
args = append(args, role)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO photo_objects (photo_id, object_id, role)
|
|
SELECT $1, object_id, role FROM photo_objects
|
|
WHERE photo_id = $2 AND role NOT IN (`+sqliteutil.Placeholders(3, len(roles))+`)`,
|
|
args...); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SQLiteStore) HardDelete(ctx context.Context, id string) error {
|
|
res, err := s.db.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT `+sqlitePhotoColumns+`, COALESCE(a.title, '')
|
|
FROM photos p
|
|
JOIN users u ON u.id = p.uploader_id
|
|
LEFT JOIN albums a ON a.id = p.album_id`+sqlitePhotoJoins+`
|
|
WHERE p.deleted_at IS NOT NULL
|
|
AND (
|
|
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
|
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
|
)
|
|
ORDER BY p.deleted_at DESC
|
|
LIMIT 200`,
|
|
userID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []models.Photo{}
|
|
for rows.Next() {
|
|
var p models.Photo
|
|
var exifText, tagsText, editText, createdAt, albumTitle string
|
|
var takenAt, deletedAt sql.NullString
|
|
var lat, long sql.NullFloat64
|
|
var liked int
|
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
|
&p.EditKey, &p.EditThumbKey, &p.EditMediumKey, &p.EditPreviewKey,
|
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalChecksum,
|
|
&p.Kind, &p.DurationMS,
|
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
|
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &albumTitle, &tagsText); err != nil {
|
|
return nil, err
|
|
}
|
|
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
|
if lat.Valid {
|
|
p.Latitude = &lat.Float64
|
|
}
|
|
if long.Valid {
|
|
p.Longitude = &long.Float64
|
|
}
|
|
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
|
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
|
|
p.AlbumTitle = albumTitle
|
|
p.LikedByMe = liked != 0
|
|
setExif(&p, exifText)
|
|
setEditParams(&p, editText)
|
|
setTags(&p, tagsText)
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) TrashCount(ctx context.Context, userID string) (int, error) {
|
|
var n int
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM photos p
|
|
LEFT JOIN albums a ON a.id = p.album_id
|
|
WHERE p.deleted_at IS NOT NULL
|
|
AND (
|
|
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
|
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
|
)`, userID).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
func (s *SQLiteStore) ListTrashIdentities(ctx context.Context, userID string) ([]TrashIdentity, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT p.id, COALESCE(p.album_id, ''), p.uploader_id
|
|
FROM photos p
|
|
LEFT JOIN albums a ON a.id = p.album_id
|
|
WHERE p.deleted_at IS NOT NULL
|
|
AND (
|
|
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
|
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
|
)
|
|
ORDER BY p.deleted_at DESC`,
|
|
userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []TrashIdentity{}
|
|
for rows.Next() {
|
|
var it TrashIdentity
|
|
if err := rows.Scan(&it.ID, &it.AlbumID, &it.UploaderID); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) RestoreToAlbum(ctx context.Context, id, albumID string) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) AlbumExists(ctx context.Context, albumID string) (bool, error) {
|
|
var ok int
|
|
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
|
|
return ok != 0, err
|
|
}
|
|
|
|
func (s *SQLiteStore) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, name FROM tags
|
|
WHERE user_id = $1 AND ($2 = '' OR name LIKE '%' || $2 || '%')
|
|
ORDER BY lower(name) LIMIT 50`, userID, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []models.Tag{}
|
|
for rows.Next() {
|
|
var t models.Tag
|
|
if err := rows.Scan(&t.ID, &t.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || len(name) > 64 {
|
|
return nil, ErrNotFound
|
|
}
|
|
var t models.Tag
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
|
Scan(&t.ID, &t.Name)
|
|
if err == nil {
|
|
return &t, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
id := auth.NewID()
|
|
err = s.db.QueryRowContext(ctx,
|
|
`INSERT INTO tags (id, user_id, name) VALUES ($1, $2, $3) RETURNING id, name`, id, userID, name).
|
|
Scan(&t.ID, &t.Name)
|
|
if err != nil {
|
|
if e2 := s.db.QueryRowContext(ctx,
|
|
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
|
Scan(&t.ID, &t.Name); e2 == nil {
|
|
return &t, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) AttachTag(ctx context.Context, photoID, tagID string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, photoID, tagID)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteStore) DetachTag(ctx context.Context, photoID, tagID string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteStore) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
|
|
var ok int
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
|
|
return ok != 0, err
|
|
}
|
|
|
|
func (s *SQLiteStore) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
|
|
albumID, photoID)
|
|
}
|
|
|
|
// ClearCoverIfMatches removes the album cover only when it currently points at
|
|
// photoID, so a concurrent cover change is not clobbered.
|
|
func (s *SQLiteStore) ClearCoverIfMatches(ctx context.Context, albumID, photoID string) {
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`,
|
|
albumID, photoID)
|
|
}
|
|
|
|
func (s *SQLiteStore) UpdateFilename(ctx context.Context, id, filename string) error {
|
|
res, err := s.db.ExecContext(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetMediaMeta records probed video facts: duration on the photo and frame
|
|
// dimensions on the original object.
|
|
func (s *SQLiteStore) SetMediaMeta(ctx context.Context, photoID, objectID string, durationMS int64, width, height int) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err := tx.ExecContext(ctx, `UPDATE photos SET duration_ms = $2 WHERE id = $1`, photoID, durationMS); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE objects SET width = $2, height = $3 WHERE id = $1`, objectID, width, height); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// BackfillEXIF mirrors the PostgreSQL implementation, reading the original blob
|
|
// of every photo with empty exif.
|
|
func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT p.id, o.storage_key
|
|
FROM photos p
|
|
JOIN photo_objects po ON po.photo_id = p.id AND po.role = 'original'
|
|
JOIN objects o ON o.id = po.object_id
|
|
WHERE p.exif IS NULL OR p.exif = '{}'
|
|
ORDER BY p.created_at`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var pending []backfillRow
|
|
for rows.Next() {
|
|
var r backfillRow
|
|
if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
|
|
rows.Close()
|
|
return 0, err
|
|
}
|
|
pending = append(pending, r)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
updated := 0
|
|
for _, r := range pending {
|
|
data, err := fetch(ctx, r.StorageKey)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
meta := extractEXIF(data)
|
|
if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
|
|
continue
|
|
}
|
|
attrs, err := json.Marshal(meta.Attributes)
|
|
if err != nil || len(attrs) == 0 {
|
|
attrs = []byte("{}")
|
|
}
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE photos SET exif = $2,
|
|
taken_at = COALESCE(taken_at, $3),
|
|
latitude = COALESCE(latitude, $4),
|
|
longitude = COALESCE(longitude, $5)
|
|
WHERE id = $1`,
|
|
r.ID, string(attrs), nullTime(meta.TakenAt), nullFloat(meta.Latitude), nullFloat(meta.Longitude)); err != nil {
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
return updated, nil
|
|
}
|