diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index beef9e5d7..9c35d103f 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -311,6 +311,8 @@ type Mutation { moveFiles(input: MoveFilesInput!): Boolean! deleteFiles(ids: [ID!]!): Boolean! + fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! + # Saved filters saveFilter(input: SaveFilterInput!): SavedFilter! destroySavedFilter(input: DestroyFilterInput!): Boolean! diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index b5a878b42..8dea777bd 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -113,3 +113,15 @@ input MoveFilesInput { "valid only for single file id. If empty, existing basename is used" destination_basename: String } + +input SetFingerprintsInput { + type: String! + "an null value will remove the fingerprint" + value: String +} + +input FileSetFingerprintsInput { + id: ID! + "only supplied fingerprint types will be modified" + fingerprints: [SetFingerprintsInput!]! +} diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index 10167a6d4..c303446e1 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -207,3 +207,68 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b return true, nil } + +func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) { + fileIDInt, err := strconv.Atoi(input.ID) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + fileID := models.FileID(fileIDInt) + + // determine what we're doing + var ( + fingerprints []models.Fingerprint + toDelete []string + ) + + for _, i := range input.Fingerprints { + if i.Type == models.FingerprintTypeMD5 || i.Type == models.FingerprintTypeOshash { + return false, fmt.Errorf("cannot modify %s fingerprint", i.Type) + } + + if i.Value == nil { + toDelete = append(toDelete, i.Type) + } else { + // phashes need to be converted from string into uint64 + var v interface{} + v = *i.Value + + if i.Type == models.FingerprintTypePhash { + vInt, err := strconv.ParseUint(*i.Value, 16, 64) + if err != nil { + return false, fmt.Errorf("converting phash %s: %w", *i.Value, err) + } + + v = vInt + } + + fingerprints = append(fingerprints, models.Fingerprint{ + Type: i.Type, + Fingerprint: v, + }) + } + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.File + + if len(fingerprints) > 0 { + if err := qb.ModifyFingerprints(ctx, fileID, fingerprints); err != nil { + return fmt.Errorf("modifying fingerprints: %w", err) + } + } + + if len(toDelete) > 0 { + if err := qb.DestroyFingerprints(ctx, fileID, toDelete); err != nil { + return fmt.Errorf("destroying fingerprints: %w", err) + } + } + + return nil + }); err != nil { + return false, err + } + + return true, nil +} diff --git a/pkg/models/mocks/FileReaderWriter.go b/pkg/models/mocks/FileReaderWriter.go index 8e7982b47..12a1b3075 100644 --- a/pkg/models/mocks/FileReaderWriter.go +++ b/pkg/models/mocks/FileReaderWriter.go @@ -86,6 +86,20 @@ func (_m *FileReaderWriter) Destroy(ctx context.Context, id models.FileID) error return r0 } +// DestroyFingerprints provides a mock function with given fields: ctx, fileID, types +func (_m *FileReaderWriter) DestroyFingerprints(ctx context.Context, fileID models.FileID, types []string) error { + ret := _m.Called(ctx, fileID, types) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, models.FileID, []string) error); ok { + r0 = rf(ctx, fileID, types) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Find provides a mock function with given fields: ctx, id func (_m *FileReaderWriter) Find(ctx context.Context, id ...models.FileID) ([]models.File, error) { _va := make([]interface{}, len(id)) @@ -298,6 +312,20 @@ func (_m *FileReaderWriter) IsPrimary(ctx context.Context, fileID models.FileID) return r0, r1 } +// ModifyFingerprints provides a mock function with given fields: ctx, fileID, fingerprints +func (_m *FileReaderWriter) ModifyFingerprints(ctx context.Context, fileID models.FileID, fingerprints []models.Fingerprint) error { + ret := _m.Called(ctx, fileID, fingerprints) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, models.FileID, []models.Fingerprint) error); ok { + r0 = rf(ctx, fileID, fingerprints) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Query provides a mock function with given fields: ctx, options func (_m *FileReaderWriter) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) { ret := _m.Called(ctx, options) diff --git a/pkg/models/repository_file.go b/pkg/models/repository_file.go index 8ea9709db..0819b25a5 100644 --- a/pkg/models/repository_file.go +++ b/pkg/models/repository_file.go @@ -72,11 +72,17 @@ type FileReader interface { IsPrimary(ctx context.Context, fileID FileID) (bool, error) } +type FileFingerprintWriter interface { + ModifyFingerprints(ctx context.Context, fileID FileID, fingerprints []Fingerprint) error + DestroyFingerprints(ctx context.Context, fileID FileID, types []string) error +} + // FileWriter provides all methods to modify files. type FileWriter interface { FileCreator FileUpdater FileDestroyer + FileFingerprintWriter UpdateCaptions(ctx context.Context, fileID FileID, captions []*VideoCaption) error } diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 2113aad13..0d7f032e1 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -361,6 +361,15 @@ func (qb *FileStore) Update(ctx context.Context, f models.File) error { return nil } +// ModifyFingerprints updates existing fingerprints and adds new ones. +func (qb *FileStore) ModifyFingerprints(ctx context.Context, fileID models.FileID, fingerprints []models.Fingerprint) error { + return FingerprintReaderWriter.upsertJoins(ctx, fileID, fingerprints) +} + +func (qb *FileStore) DestroyFingerprints(ctx context.Context, fileID models.FileID, types []string) error { + return FingerprintReaderWriter.destroyJoins(ctx, fileID, types) +} + func (qb *FileStore) Destroy(ctx context.Context, id models.FileID) error { return qb.tableMgr.destroyExisting(ctx, []int{int(id)}) } diff --git a/pkg/sqlite/fingerprint.go b/pkg/sqlite/fingerprint.go index 49bae54ca..d65f6bab5 100644 --- a/pkg/sqlite/fingerprint.go +++ b/pkg/sqlite/fingerprint.go @@ -68,6 +68,25 @@ func (qb *fingerprintQueryBuilder) insertJoins(ctx context.Context, fileID model return nil } +func (qb *fingerprintQueryBuilder) upsertJoins(ctx context.Context, fileID models.FileID, f []models.Fingerprint) error { + types := make([]string, len(f)) + for i, ff := range f { + types[i] = ff.Type + } + + if err := qb.destroyJoins(ctx, fileID, types); err != nil { + return err + } + + for _, ff := range f { + if err := qb.insert(ctx, fileID, ff); err != nil { + return err + } + } + + return nil +} + func (qb *fingerprintQueryBuilder) replaceJoins(ctx context.Context, fileID models.FileID, f []models.Fingerprint) error { if err := qb.destroy(ctx, []int{int(fileID)}); err != nil { return err @@ -76,6 +95,21 @@ func (qb *fingerprintQueryBuilder) replaceJoins(ctx context.Context, fileID mode return qb.insertJoins(ctx, fileID, f) } +func (qb *fingerprintQueryBuilder) destroyJoins(ctx context.Context, fileID models.FileID, types []string) error { + table := qb.table() + q := dialect.Delete(table).Where( + table.Col(fileIDColumn).Eq(fileID), + table.Col("type").In(types), + ) + + _, err := exec(ctx, q) + if err != nil { + return fmt.Errorf("deleting from %s: %w", table.GetTable(), err) + } + + return nil +} + func (qb *fingerprintQueryBuilder) table() exp.IdentifierExpression { return qb.tableMgr.table }