mirror of
https://git.vectorsigma.ru/public/photoview.git
synced 2026-08-03 21:09:05 +00:00
Fix gqlgen (#1105)
This commit is contained in:
22
.github/workflows/build.yml
vendored
22
.github/workflows/build.yml
vendored
@@ -1,20 +1,22 @@
|
|||||||
name: Docker builds
|
name: Docker builds
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: ['*']
|
||||||
tags:
|
tags:
|
||||||
- v*
|
- v*
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
schedule:
|
schedule:
|
||||||
# At 01:18 every Thursday. Details in https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule
|
# At 01:18 every Thursday. Details in https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule
|
||||||
- cron: '18 1 * * 4'
|
- cron: '18 1 * * 4'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IS_PUSHING_IMAGES: ${{ github.event_name != 'pull_request' && github.repository == 'photoview/photoview' }}
|
IS_PUSHING_IMAGES: ${{ github.event_name != 'pull_request' && github.repository == 'photoview/photoview' }}
|
||||||
|
# IS_PUSHING_IMAGES: true # Switch on when pushing images with forked repos.
|
||||||
|
DOCKER_REGISTRY: ${{ (github.repository == 'photoview/photoview' && '') || 'ghcr.io/' }}
|
||||||
DOCKER_USERNAME: viktorstrate
|
DOCKER_USERNAME: viktorstrate
|
||||||
DOCKER_IMAGE: viktorstrate/photoview
|
DOCKER_IMAGE: ${{ (github.repository == 'photoview/photoview' && 'viktorstrate/photoview') || github.repository }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7
|
PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
|
||||||
@@ -82,7 +84,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
platforms: ${{ env.PLATFORMS }}
|
platforms: ${{ env.PLATFORMS }}
|
||||||
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -90,15 +91,16 @@ jobs:
|
|||||||
if: ${{ env.IS_PUSHING_IMAGES == 'true' }}
|
if: ${{ env.IS_PUSHING_IMAGES == 'true' }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ env.DOCKER_USERNAME }}
|
registry: ${{ env.DOCKER_REGISTRY == 'ghcr.io/' && 'ghcr.io' }}
|
||||||
password: ${{ env.DOCKER_PASSWORD }}
|
username: ${{ ( env.DOCKER_REGISTRY == 'ghcr.io/' && github.actor ) || env.DOCKER_USERNAME }}
|
||||||
|
password: ${{ ( env.DOCKER_REGISTRY == 'ghcr.io/' && github.token ) || env.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: docker_meta
|
id: docker_meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: ${{ env.DOCKER_IMAGE }}
|
images: ${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}
|
||||||
# Docker tags based on the following events/attributes
|
# Docker tags based on the following events/attributes
|
||||||
tags: |
|
tags: |
|
||||||
type=schedule
|
type=schedule
|
||||||
@@ -152,13 +154,13 @@ jobs:
|
|||||||
username: ${{ env.DOCKER_USERNAME }}
|
username: ${{ env.DOCKER_USERNAME }}
|
||||||
password: ${{ env.DOCKER_PASSWORD }}
|
password: ${{ env.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Run Dockle for '${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
|
- name: Run Dockle for '${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
|
||||||
id: dockle
|
id: dockle
|
||||||
if: ${{ matrix.tags.tag != '' }}
|
if: ${{ matrix.tags.tag != '' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: erzz/dockle-action@v1
|
uses: erzz/dockle-action@v1
|
||||||
with:
|
with:
|
||||||
image: '${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
|
image: '${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
|
||||||
report-name: dockle-results-${{ matrix.tags.tag }}
|
report-name: dockle-results-${{ matrix.tags.tag }}
|
||||||
report-format: sarif
|
report-format: sarif
|
||||||
failure-threshold: fatal
|
failure-threshold: fatal
|
||||||
|
|||||||
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
@@ -2,7 +2,7 @@ name: Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: ['*']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
@@ -69,12 +69,13 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
docker run --name test --network host \
|
docker run --name test --network host \
|
||||||
|
-v "${{ github.workspace }}:/app" \
|
||||||
-e PHOTOVIEW_DATABASE_DRIVER=${{ matrix.database }} \
|
-e PHOTOVIEW_DATABASE_DRIVER=${{ matrix.database }} \
|
||||||
-e PHOTOVIEW_MYSQL_URL='photoview:photosecret@tcp(localhost:3306)/photoview_test' \
|
-e PHOTOVIEW_MYSQL_URL='photoview:photosecret@tcp(localhost:3306)/photoview_test' \
|
||||||
-e PHOTOVIEW_POSTGRES_URL='postgres://photoview:photosecret@localhost:5432/photoview_test' \
|
-e PHOTOVIEW_POSTGRES_URL='postgres://photoview:photosecret@localhost:5432/photoview_test' \
|
||||||
-e PHOTOVIEW_SQLITE_PATH=/tmp/photoview.db \
|
-e PHOTOVIEW_SQLITE_PATH=/tmp/photoview.db \
|
||||||
photoview/api \
|
photoview/api \
|
||||||
go test ./... -v -database -filesystem -p 1 -coverprofile=coverage.txt -covermode=atomic
|
/app/scripts/test_all.sh
|
||||||
docker cp test:/app/api/coverage.txt ./api/
|
docker cp test:/app/api/coverage.txt ./api/
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ COPY --from=viktorstrate/dependencies /artifacts.tar.gz /dependencies/
|
|||||||
# Split values in `/env`
|
# Split values in `/env`
|
||||||
# hadolint ignore=SC2046
|
# hadolint ignore=SC2046
|
||||||
RUN export $(cat /env) \
|
RUN export $(cat /env) \
|
||||||
|
&& git config --global --add safe.directory /app \
|
||||||
&& cd /dependencies/ \
|
&& cd /dependencies/ \
|
||||||
&& tar xfv artifacts.tar.gz \
|
&& tar xfv artifacts.tar.gz \
|
||||||
&& cp -a include/* /usr/local/include/ \
|
&& cp -a include/* /usr/local/include/ \
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ resolver:
|
|||||||
layout: follow-schema
|
layout: follow-schema
|
||||||
dir: graphql/resolvers
|
dir: graphql/resolvers
|
||||||
package: resolvers
|
package: resolvers
|
||||||
filename_template: "{name}.generated.go"
|
filename_template: "{name}.go"
|
||||||
|
|
||||||
autobind: []
|
autobind: []
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,7 @@ type Subscription struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A group of media from the same album and the same day, that is grouped together in a timeline view
|
// A group of media from the same album and the same day, that is grouped together in a timeline view
|
||||||
|
// NOTE: It isn't used. Just copy from the old schema.graphql.
|
||||||
type TimelineGroup struct {
|
type TimelineGroup struct {
|
||||||
// The full album containing the media in this timeline group
|
// The full album containing the media in this timeline group
|
||||||
Album *Album `json:"album"`
|
Album *Album `json:"album"`
|
||||||
|
|||||||
@@ -1,410 +0,0 @@
|
|||||||
package resolvers
|
|
||||||
|
|
||||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
|
||||||
// will be copied through when generating and any unknown code will be moved to the end.
|
|
||||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
api "github.com/photoview/photoview/api/graphql"
|
|
||||||
"github.com/photoview/photoview/api/graphql/auth"
|
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
|
||||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ImageFaces is the resolver for the imageFaces field.
|
|
||||||
func (r *faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := user.FillAlbums(db); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userAlbumIDs := make([]int, len(user.Albums))
|
|
||||||
for i, album := range user.Albums {
|
|
||||||
userAlbumIDs[i] = album.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
query := db.
|
|
||||||
Joins("Media").
|
|
||||||
Where(faceGroupIDIsQuestion, obj.ID).
|
|
||||||
Where("album_id IN (?)", userAlbumIDs)
|
|
||||||
|
|
||||||
query = models.FormatSQL(query, nil, paginate)
|
|
||||||
|
|
||||||
var imageFaces []*models.ImageFace
|
|
||||||
if err := query.Find(&imageFaces).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return imageFaces, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageFaceCount is the resolver for the imageFaceCount field.
|
|
||||||
func (r *faceGroupResolver) ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return -1, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return -1, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := user.FillAlbums(db); err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userAlbumIDs := make([]int, len(user.Albums))
|
|
||||||
for i, album := range user.Albums {
|
|
||||||
userAlbumIDs[i] = album.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
query := db.
|
|
||||||
Model(&models.ImageFace{}).
|
|
||||||
Joins("Media").
|
|
||||||
Where(faceGroupIDIsQuestion, obj.ID).
|
|
||||||
Where("album_id IN (?)", userAlbumIDs)
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
if err := query.Count(&count).Error; err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return int(count), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Media is the resolver for the media field.
|
|
||||||
func (r *imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
|
|
||||||
if err := obj.FillMedia(r.DB(ctx)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &obj.Media, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FaceGroup is the resolver for the faceGroup field.
|
|
||||||
func (r *imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) {
|
|
||||||
if obj.FaceGroup != nil {
|
|
||||||
return obj.FaceGroup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
var faceGroup models.FaceGroup
|
|
||||||
if err := r.DB(ctx).Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
obj.FaceGroup = &faceGroup
|
|
||||||
|
|
||||||
return &faceGroup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetFaceGroupLabel is the resolver for the setFaceGroupLabel field.
|
|
||||||
func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
faceGroup, err := userOwnedFaceGroup(db, user, faceGroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Model(faceGroup).Update("label", label).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return faceGroup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CombineFaceGroups is the resolver for the combineFaceGroups field.
|
|
||||||
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
destinationFaceGroup, err := userOwnedFaceGroup(db, user, destinationFaceGroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceFaceGroup, err := userOwnedFaceGroup(db, user, sourceFaceGroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
updateError := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&models.ImageFace{}).
|
|
||||||
Where(faceGroupIDIsQuestion, sourceFaceGroup.ID).
|
|
||||||
Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if updateError != nil {
|
|
||||||
return nil, updateError
|
|
||||||
}
|
|
||||||
|
|
||||||
face_detection.GlobalFaceDetector.MergeCategories(int32(sourceFaceGroupID), int32(destinationFaceGroupID))
|
|
||||||
|
|
||||||
return destinationFaceGroup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveImageFaces is the resolver for the moveImageFaces field.
|
|
||||||
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
userOwnedImageFaceIDs := make([]int, 0)
|
|
||||||
var destFaceGroup *models.FaceGroup
|
|
||||||
|
|
||||||
transErr := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
|
|
||||||
var err error
|
|
||||||
destFaceGroup, err = userOwnedFaceGroup(tx, user, destinationFaceGroupID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, imageFace := range userOwnedImageFaces {
|
|
||||||
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceFaceGroups []*models.FaceGroup
|
|
||||||
if err := tx.
|
|
||||||
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
|
||||||
Where(imageFacesIDInQuestion, userOwnedImageFaceIDs).
|
|
||||||
Find(&sourceFaceGroups).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.
|
|
||||||
Model(&models.ImageFace{}).
|
|
||||||
Where("id IN (?)", userOwnedImageFaceIDs).
|
|
||||||
Update("face_group_id", destFaceGroup.ID).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete face groups if they have become empty
|
|
||||||
if err := deleteEmptyFaceGroups(sourceFaceGroups, tx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if transErr != nil {
|
|
||||||
return nil, transErr
|
|
||||||
}
|
|
||||||
|
|
||||||
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(destFaceGroup.ID))
|
|
||||||
|
|
||||||
return destFaceGroup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecognizeUnlabeledFaces is the resolver for the recognizeUnlabeledFaces field.
|
|
||||||
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
var updatedImageFaces []*models.ImageFace
|
|
||||||
|
|
||||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var err error
|
|
||||||
updatedImageFaces, err = face_detection.GlobalFaceDetector.RecognizeUnlabeledFaces(tx, user)
|
|
||||||
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
if transactionError != nil {
|
|
||||||
return nil, transactionError
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedImageFaces, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DetachImageFaces is the resolver for the detachImageFaces field.
|
|
||||||
func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
userOwnedImageFaceIDs := make([]int, 0)
|
|
||||||
newFaceGroup := models.FaceGroup{}
|
|
||||||
|
|
||||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
|
|
||||||
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, imageFace := range userOwnedImageFaces {
|
|
||||||
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Save(&newFaceGroup).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.
|
|
||||||
Model(&models.ImageFace{}).
|
|
||||||
Where("id IN (?)", userOwnedImageFaceIDs).
|
|
||||||
Update("face_group_id", newFaceGroup.ID).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if transactionError != nil {
|
|
||||||
return nil, transactionError
|
|
||||||
}
|
|
||||||
|
|
||||||
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(newFaceGroup.ID))
|
|
||||||
|
|
||||||
return &newFaceGroup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MyFaceGroups is the resolver for the myFaceGroups field.
|
|
||||||
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := user.FillAlbums(db); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userAlbumIDs := make([]int, len(user.Albums))
|
|
||||||
for i, album := range user.Albums {
|
|
||||||
userAlbumIDs[i] = album.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
faceGroupQuery := db.
|
|
||||||
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
|
||||||
Where("image_faces.media_id IN (?)",
|
|
||||||
db.Select("media.id").Table("media").Where(mediaAlbumIDInQuestion, userAlbumIDs)).
|
|
||||||
Group("image_faces.face_group_id").
|
|
||||||
Group("face_groups.id").
|
|
||||||
Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END").
|
|
||||||
Order("COUNT(image_faces.id) DESC")
|
|
||||||
|
|
||||||
faceGroupQuery = models.FormatSQL(faceGroupQuery, nil, paginate)
|
|
||||||
|
|
||||||
var faceGroups []*models.FaceGroup
|
|
||||||
if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return faceGroups, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FaceGroup is the resolver for the faceGroup field.
|
|
||||||
func (r *queryResolver) FaceGroup(ctx context.Context, id int) (*models.FaceGroup, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("unauthorized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if face_detection.GlobalFaceDetector == nil {
|
|
||||||
return nil, ErrFaceDetectorNotInitialized
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := user.FillAlbums(db); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userAlbumIDs := make([]int, len(user.Albums))
|
|
||||||
for i, album := range user.Albums {
|
|
||||||
userAlbumIDs[i] = album.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
faceGroupQuery := db.
|
|
||||||
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
|
||||||
Joins("LEFT JOIN media ON image_faces.media_id = media.id").
|
|
||||||
Where("face_groups.id = ?", id).
|
|
||||||
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
|
||||||
|
|
||||||
var faceGroup models.FaceGroup
|
|
||||||
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &faceGroup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FaceGroup returns api.FaceGroupResolver implementation.
|
|
||||||
func (r *Resolver) FaceGroup() api.FaceGroupResolver { return &faceGroupResolver{r} }
|
|
||||||
|
|
||||||
// ImageFace returns api.ImageFaceResolver implementation.
|
|
||||||
func (r *Resolver) ImageFace() api.ImageFaceResolver { return &imageFaceResolver{r} }
|
|
||||||
|
|
||||||
type faceGroupResolver struct{ *Resolver }
|
|
||||||
type imageFaceResolver struct{ *Resolver }
|
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
import (
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
"errors"
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
"fmt"
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
api "github.com/photoview/photoview/api/graphql"
|
||||||
|
"github.com/photoview/photoview/api/graphql/auth"
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const faceGroupIDIsQuestion = "face_group_id = ?"
|
// ImageFaces is the resolver for the imageFaces field.
|
||||||
const mediaAlbumIDInQuestion = "media.album_id IN (?)"
|
func (r *faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
|
||||||
const imageFacesIDInQuestion = "image_faces.id IN (?)"
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
var ErrFaceDetectorNotInitialized = errors.New("face detector not initialized")
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
|
|
||||||
if user.Admin {
|
|
||||||
var faceGroup models.FaceGroup
|
|
||||||
if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &faceGroup, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := user.FillAlbums(db); err != nil {
|
if err := user.FillAlbums(db); err != nil {
|
||||||
@@ -33,32 +36,308 @@ func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*model
|
|||||||
userAlbumIDs[i] = album.ID
|
userAlbumIDs[i] = album.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that user owns at leat one of the images in the face group
|
query := db.
|
||||||
imageFaceQuery := db.
|
Joins("Media").
|
||||||
Select("image_faces.id").
|
Where(faceGroupIDIsQuestion, obj.ID).
|
||||||
Table("image_faces").
|
Where("album_id IN (?)", userAlbumIDs)
|
||||||
Joins("JOIN media ON media.id = image_faces.media_id").
|
|
||||||
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
|
||||||
|
|
||||||
faceGroupQuery := db.
|
query = models.FormatSQL(query, nil, paginate)
|
||||||
Model(&models.FaceGroup{}).
|
|
||||||
Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
|
|
||||||
Where("face_groups.id = ?", faceGroupID).
|
|
||||||
Where(imageFacesIDInQuestion, imageFaceQuery)
|
|
||||||
|
|
||||||
var faceGroup models.FaceGroup
|
var imageFaces []*models.ImageFace
|
||||||
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
if err := query.Find(&imageFaces).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return nil, fmt.Errorf("face group does not exist or is not owned by the user: %w", err)
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return imageFaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageFaceCount is the resolver for the imageFaceCount field.
|
||||||
|
func (r *faceGroupResolver) ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error) {
|
||||||
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return -1, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return -1, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.FillAlbums(db); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAlbumIDs := make([]int, len(user.Albums))
|
||||||
|
for i, album := range user.Albums {
|
||||||
|
userAlbumIDs[i] = album.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
query := db.
|
||||||
|
Model(&models.ImageFace{}).
|
||||||
|
Joins("Media").
|
||||||
|
Where(faceGroupIDIsQuestion, obj.ID).
|
||||||
|
Where("album_id IN (?)", userAlbumIDs)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := query.Count(&count).Error; err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media is the resolver for the media field.
|
||||||
|
func (r *imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
|
||||||
|
if err := obj.FillMedia(r.DB(ctx)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &obj.Media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FaceGroup is the resolver for the faceGroup field.
|
||||||
|
func (r *imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) {
|
||||||
|
if obj.FaceGroup != nil {
|
||||||
|
return obj.FaceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
var faceGroup models.FaceGroup
|
||||||
|
if err := r.DB(ctx).Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.FaceGroup = &faceGroup
|
||||||
|
|
||||||
return &faceGroup, nil
|
return &faceGroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
|
// SetFaceGroupLabel is the resolver for the setFaceGroupLabel field.
|
||||||
if err := user.FillAlbums(tx); err != nil {
|
func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) {
|
||||||
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
faceGroup, err := userOwnedFaceGroup(db, user, faceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Model(faceGroup).Update("label", label).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return faceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CombineFaceGroups is the resolver for the combineFaceGroups field.
|
||||||
|
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) {
|
||||||
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationFaceGroup, err := userOwnedFaceGroup(db, user, destinationFaceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceFaceGroup, err := userOwnedFaceGroup(db, user, sourceFaceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updateError := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&models.ImageFace{}).
|
||||||
|
Where(faceGroupIDIsQuestion, sourceFaceGroup.ID).
|
||||||
|
Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if updateError != nil {
|
||||||
|
return nil, updateError
|
||||||
|
}
|
||||||
|
|
||||||
|
face_detection.GlobalFaceDetector.MergeCategories(int32(sourceFaceGroupID), int32(destinationFaceGroupID))
|
||||||
|
|
||||||
|
return destinationFaceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveImageFaces is the resolver for the moveImageFaces field.
|
||||||
|
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
|
||||||
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
userOwnedImageFaceIDs := make([]int, 0)
|
||||||
|
var destFaceGroup *models.FaceGroup
|
||||||
|
|
||||||
|
transErr := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|
||||||
|
var err error
|
||||||
|
destFaceGroup, err = userOwnedFaceGroup(tx, user, destinationFaceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, imageFace := range userOwnedImageFaces {
|
||||||
|
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceFaceGroups []*models.FaceGroup
|
||||||
|
if err := tx.
|
||||||
|
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||||
|
Where(imageFacesIDInQuestion, userOwnedImageFaceIDs).
|
||||||
|
Find(&sourceFaceGroups).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.
|
||||||
|
Model(&models.ImageFace{}).
|
||||||
|
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||||
|
Update("face_group_id", destFaceGroup.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete face groups if they have become empty
|
||||||
|
if err := deleteEmptyFaceGroups(sourceFaceGroups, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if transErr != nil {
|
||||||
|
return nil, transErr
|
||||||
|
}
|
||||||
|
|
||||||
|
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(destFaceGroup.ID))
|
||||||
|
|
||||||
|
return destFaceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecognizeUnlabeledFaces is the resolver for the recognizeUnlabeledFaces field.
|
||||||
|
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
|
||||||
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedImageFaces []*models.ImageFace
|
||||||
|
|
||||||
|
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
updatedImageFaces, err = face_detection.GlobalFaceDetector.RecognizeUnlabeledFaces(tx, user)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if transactionError != nil {
|
||||||
|
return nil, transactionError
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedImageFaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetachImageFaces is the resolver for the detachImageFaces field.
|
||||||
|
func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) {
|
||||||
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
userOwnedImageFaceIDs := make([]int, 0)
|
||||||
|
newFaceGroup := models.FaceGroup{}
|
||||||
|
|
||||||
|
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|
||||||
|
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, imageFace := range userOwnedImageFaces {
|
||||||
|
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Save(&newFaceGroup).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.
|
||||||
|
Model(&models.ImageFace{}).
|
||||||
|
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||||
|
Update("face_group_id", newFaceGroup.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if transactionError != nil {
|
||||||
|
return nil, transactionError
|
||||||
|
}
|
||||||
|
|
||||||
|
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(newFaceGroup.ID))
|
||||||
|
|
||||||
|
return &newFaceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyFaceGroups is the resolver for the myFaceGroups field.
|
||||||
|
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
|
||||||
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.FillAlbums(db); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,30 +346,65 @@ func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int)
|
|||||||
userAlbumIDs[i] = album.ID
|
userAlbumIDs[i] = album.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
var userOwnedImageFaces []*models.ImageFace
|
faceGroupQuery := db.
|
||||||
if err := tx.
|
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||||
Joins("JOIN media ON media.id = image_faces.media_id").
|
Where("image_faces.media_id IN (?)",
|
||||||
Where(mediaAlbumIDInQuestion, userAlbumIDs).
|
db.Select("media.id").Table("media").Where(mediaAlbumIDInQuestion, userAlbumIDs)).
|
||||||
Where(imageFacesIDInQuestion, imageFaceIDs).
|
Group("image_faces.face_group_id").
|
||||||
Find(&userOwnedImageFaces).Error; err != nil {
|
Group("face_groups.id").
|
||||||
|
Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END").
|
||||||
|
Order("COUNT(image_faces.id) DESC")
|
||||||
|
|
||||||
|
faceGroupQuery = models.FormatSQL(faceGroupQuery, nil, paginate)
|
||||||
|
|
||||||
|
var faceGroups []*models.FaceGroup
|
||||||
|
if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return userOwnedImageFaces, nil
|
return faceGroups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteEmptyFaceGroups(sourceFaceGroups []*models.FaceGroup, tx *gorm.DB) error {
|
// FaceGroup is the resolver for the faceGroup field.
|
||||||
for _, faceGroup := range sourceFaceGroups {
|
func (r *queryResolver) FaceGroup(ctx context.Context, id int) (*models.FaceGroup, error) {
|
||||||
var count int64
|
db := r.DB(ctx)
|
||||||
if err := tx.Model(&models.ImageFace{}).Where(faceGroupIDIsQuestion, faceGroup.ID).Count(&count).Error; err != nil {
|
user := auth.UserFromContext(ctx)
|
||||||
return err
|
if user == nil {
|
||||||
}
|
return nil, errors.New("unauthorized")
|
||||||
|
|
||||||
if count == 0 {
|
|
||||||
if err := tx.Delete(&faceGroup).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
if face_detection.GlobalFaceDetector == nil {
|
||||||
|
return nil, ErrFaceDetectorNotInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.FillAlbums(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAlbumIDs := make([]int, len(user.Albums))
|
||||||
|
for i, album := range user.Albums {
|
||||||
|
userAlbumIDs[i] = album.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
faceGroupQuery := db.
|
||||||
|
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||||
|
Joins("LEFT JOIN media ON image_faces.media_id = media.id").
|
||||||
|
Where("face_groups.id = ?", id).
|
||||||
|
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
||||||
|
|
||||||
|
var faceGroup models.FaceGroup
|
||||||
|
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &faceGroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FaceGroup returns api.FaceGroupResolver implementation.
|
||||||
|
func (r *Resolver) FaceGroup() api.FaceGroupResolver { return &faceGroupResolver{r} }
|
||||||
|
|
||||||
|
// ImageFace returns api.ImageFaceResolver implementation.
|
||||||
|
func (r *Resolver) ImageFace() api.ImageFaceResolver { return &imageFaceResolver{r} }
|
||||||
|
|
||||||
|
type faceGroupResolver struct{ *Resolver }
|
||||||
|
type imageFaceResolver struct{ *Resolver }
|
||||||
|
|||||||
96
api/graphql/resolvers/faces.util.go
Normal file
96
api/graphql/resolvers/faces.util.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package resolvers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const faceGroupIDIsQuestion = "face_group_id = ?"
|
||||||
|
const mediaAlbumIDInQuestion = "media.album_id IN (?)"
|
||||||
|
const imageFacesIDInQuestion = "image_faces.id IN (?)"
|
||||||
|
|
||||||
|
var ErrFaceDetectorNotInitialized = errors.New("face detector not initialized")
|
||||||
|
|
||||||
|
func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
|
||||||
|
if user.Admin {
|
||||||
|
var faceGroup models.FaceGroup
|
||||||
|
if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &faceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.FillAlbums(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAlbumIDs := make([]int, len(user.Albums))
|
||||||
|
for i, album := range user.Albums {
|
||||||
|
userAlbumIDs[i] = album.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that user owns at leat one of the images in the face group
|
||||||
|
imageFaceQuery := db.
|
||||||
|
Select("image_faces.id").
|
||||||
|
Table("image_faces").
|
||||||
|
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||||
|
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
||||||
|
|
||||||
|
faceGroupQuery := db.
|
||||||
|
Model(&models.FaceGroup{}).
|
||||||
|
Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
|
||||||
|
Where("face_groups.id = ?", faceGroupID).
|
||||||
|
Where(imageFacesIDInQuestion, imageFaceQuery)
|
||||||
|
|
||||||
|
var faceGroup models.FaceGroup
|
||||||
|
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, fmt.Errorf("face group does not exist or is not owned by the user: %w", err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &faceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
|
||||||
|
if err := user.FillAlbums(tx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAlbumIDs := make([]int, len(user.Albums))
|
||||||
|
for i, album := range user.Albums {
|
||||||
|
userAlbumIDs[i] = album.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var userOwnedImageFaces []*models.ImageFace
|
||||||
|
if err := tx.
|
||||||
|
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||||
|
Where(mediaAlbumIDInQuestion, userAlbumIDs).
|
||||||
|
Where(imageFacesIDInQuestion, imageFaceIDs).
|
||||||
|
Find(&userOwnedImageFaces).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return userOwnedImageFaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEmptyFaceGroups(sourceFaceGroups []*models.FaceGroup, tx *gorm.DB) error {
|
||||||
|
for _, faceGroup := range sourceFaceGroups {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&models.ImageFace{}).Where(faceGroupIDIsQuestion, faceGroup.ID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
if err := tx.Delete(&faceGroup).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -20,6 +20,14 @@ enum MediaType {
|
|||||||
Video
|
Video
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Coordinates {
|
||||||
|
"GPS latitude in degrees"
|
||||||
|
latitude: Float!
|
||||||
|
"GPS longitude in degrees"
|
||||||
|
longitude: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
"EXIF metadata from the camera"
|
"EXIF metadata from the camera"
|
||||||
type MediaEXIF {
|
type MediaEXIF {
|
||||||
id: ID!
|
id: ID!
|
||||||
@@ -49,6 +57,20 @@ type MediaEXIF {
|
|||||||
coordinates: Coordinates
|
coordinates: Coordinates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"Metadata specific to video media"
|
||||||
|
type VideoMetadata {
|
||||||
|
id: ID!
|
||||||
|
media: Media!
|
||||||
|
width: Int!
|
||||||
|
height: Int!
|
||||||
|
duration: Float!
|
||||||
|
codec: String
|
||||||
|
framerate: Float
|
||||||
|
bitrate: String
|
||||||
|
colorProfile: String
|
||||||
|
audio: String
|
||||||
|
}
|
||||||
|
|
||||||
type Media {
|
type Media {
|
||||||
id: ID!
|
id: ID!
|
||||||
title: String!
|
title: String!
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package resolvers
|
|
||||||
|
|
||||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
|
||||||
// will be copied through when generating and any unknown code will be moved to the end.
|
|
||||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/graphql/auth"
|
|
||||||
"github.com/photoview/photoview/api/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MyMediaGeoJSON is the resolver for the myMediaGeoJson field.
|
|
||||||
func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (any, error) {
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, auth.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
var media []*geoMedia
|
|
||||||
|
|
||||||
err := r.DB(ctx).Table("media").
|
|
||||||
Select("media.id AS media_id, media.title AS media_title, "+
|
|
||||||
"media_urls.media_name AS thumbnail_name, media_urls.width AS thumbnail_width, "+
|
|
||||||
"media_urls.height AS thumbnail_height, media_exif.gps_latitude AS latitude, "+
|
|
||||||
"media_exif.gps_longitude AS longitude").
|
|
||||||
Joins("INNER JOIN media_exif ON media.exif_id = media_exif.id").
|
|
||||||
Joins("INNER JOIN media_urls ON media.id = media_urls.media_id").
|
|
||||||
Joins("INNER JOIN user_albums ON media.album_id = user_albums.album_id").
|
|
||||||
Where("media_exif.gps_latitude IS NOT NULL").
|
|
||||||
Where("media_exif.gps_longitude IS NOT NULL").
|
|
||||||
Where("media_urls.purpose = 'thumbnail'").
|
|
||||||
Where("user_albums.user_id = ?", user.ID).
|
|
||||||
Scan(&media).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
features := make([]geoJSONFeature, 0)
|
|
||||||
|
|
||||||
for _, item := range media {
|
|
||||||
geoPoint := makeGeoJSONFeatureGeometryPoint(item.Latitude, item.Longitude)
|
|
||||||
|
|
||||||
thumbnailURL := utils.ApiEndpointUrl()
|
|
||||||
thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", item.ThumbnailName)
|
|
||||||
|
|
||||||
properties := geoJSONMediaProperties{
|
|
||||||
MediaID: item.MediaID,
|
|
||||||
MediaTitle: item.MediaTitle,
|
|
||||||
Thumbnail: struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Width int `json:"width"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
}{
|
|
||||||
URL: thumbnailURL.String(),
|
|
||||||
Width: item.ThumbnailWidth,
|
|
||||||
Height: item.ThumbnailHeight,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
features = append(features, makeGeoJSONFeature(properties, geoPoint))
|
|
||||||
}
|
|
||||||
|
|
||||||
featureCollection := makeGeoJSONFeatureCollection(features)
|
|
||||||
return featureCollection, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MapboxToken is the resolver for the mapboxToken field.
|
|
||||||
func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) {
|
|
||||||
mapboxTokenEnv := os.Getenv("MAPBOX_TOKEN")
|
|
||||||
if mapboxTokenEnv == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &mapboxTokenEnv, nil
|
|
||||||
}
|
|
||||||
@@ -1,61 +1,80 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
type geoMedia struct {
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
MediaID int
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
MediaTitle string
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
ThumbnailName string
|
|
||||||
ThumbnailWidth int
|
|
||||||
ThumbnailHeight int
|
|
||||||
Latitude float64
|
|
||||||
Longitude float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type geoJSONFeatureCollection struct {
|
import (
|
||||||
Type string `json:"type"`
|
"context"
|
||||||
Features []geoJSONFeature `json:"features"`
|
"os"
|
||||||
}
|
"path"
|
||||||
|
|
||||||
type geoJSONFeature struct {
|
"github.com/photoview/photoview/api/graphql/auth"
|
||||||
Type string `json:"type"`
|
"github.com/photoview/photoview/api/utils"
|
||||||
Properties interface{} `json:"properties"`
|
)
|
||||||
Geometry geoJSONFeatureGeometry `json:"geometry"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type geoJSONMediaProperties struct {
|
// MyMediaGeoJSON is the resolver for the myMediaGeoJson field.
|
||||||
MediaID int `json:"media_id"`
|
func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (any, error) {
|
||||||
MediaTitle string `json:"media_title"`
|
user := auth.UserFromContext(ctx)
|
||||||
Thumbnail struct {
|
if user == nil {
|
||||||
URL string `json:"url"`
|
return nil, auth.ErrUnauthorized
|
||||||
Width int `json:"width"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
} `json:"thumbnail"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type geoJSONFeatureGeometry struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Coordinates [2]float64 `json:"coordinates"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeGeoJSONFeatureCollection(features []geoJSONFeature) geoJSONFeatureCollection {
|
|
||||||
return geoJSONFeatureCollection{
|
|
||||||
Type: "FeatureCollection",
|
|
||||||
Features: features,
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature {
|
var media []*geoMedia
|
||||||
return geoJSONFeature{
|
|
||||||
Type: "Feature",
|
err := r.DB(ctx).Table("media").
|
||||||
Properties: properties,
|
Select("media.id AS media_id, media.title AS media_title, "+
|
||||||
Geometry: geometry,
|
"media_urls.media_name AS thumbnail_name, media_urls.width AS thumbnail_width, "+
|
||||||
|
"media_urls.height AS thumbnail_height, media_exif.gps_latitude AS latitude, "+
|
||||||
|
"media_exif.gps_longitude AS longitude").
|
||||||
|
Joins("INNER JOIN media_exif ON media.exif_id = media_exif.id").
|
||||||
|
Joins("INNER JOIN media_urls ON media.id = media_urls.media_id").
|
||||||
|
Joins("INNER JOIN user_albums ON media.album_id = user_albums.album_id").
|
||||||
|
Where("media_exif.gps_latitude IS NOT NULL").
|
||||||
|
Where("media_exif.gps_longitude IS NOT NULL").
|
||||||
|
Where("media_urls.purpose = 'thumbnail'").
|
||||||
|
Where("user_albums.user_id = ?", user.ID).
|
||||||
|
Scan(&media).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry {
|
features := make([]geoJSONFeature, 0)
|
||||||
coordinates := [2]float64{long, lat}
|
|
||||||
|
|
||||||
return geoJSONFeatureGeometry{
|
for _, item := range media {
|
||||||
Type: "Point",
|
geoPoint := makeGeoJSONFeatureGeometryPoint(item.Latitude, item.Longitude)
|
||||||
Coordinates: coordinates,
|
|
||||||
|
thumbnailURL := utils.ApiEndpointUrl()
|
||||||
|
thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", item.ThumbnailName)
|
||||||
|
|
||||||
|
properties := geoJSONMediaProperties{
|
||||||
|
MediaID: item.MediaID,
|
||||||
|
MediaTitle: item.MediaTitle,
|
||||||
|
Thumbnail: struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}{
|
||||||
|
URL: thumbnailURL.String(),
|
||||||
|
Width: item.ThumbnailWidth,
|
||||||
|
Height: item.ThumbnailHeight,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
features = append(features, makeGeoJSONFeature(properties, geoPoint))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
featureCollection := makeGeoJSONFeatureCollection(features)
|
||||||
|
return featureCollection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapboxToken is the resolver for the mapboxToken field.
|
||||||
|
func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) {
|
||||||
|
mapboxTokenEnv := os.Getenv("MAPBOX_TOKEN")
|
||||||
|
if mapboxTokenEnv == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mapboxTokenEnv, nil
|
||||||
}
|
}
|
||||||
|
|||||||
61
api/graphql/resolvers/media_geo_json.util.go
Normal file
61
api/graphql/resolvers/media_geo_json.util.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package resolvers
|
||||||
|
|
||||||
|
type geoMedia struct {
|
||||||
|
MediaID int
|
||||||
|
MediaTitle string
|
||||||
|
ThumbnailName string
|
||||||
|
ThumbnailWidth int
|
||||||
|
ThumbnailHeight int
|
||||||
|
Latitude float64
|
||||||
|
Longitude float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoJSONFeatureCollection struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Features []geoJSONFeature `json:"features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoJSONFeature struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Properties interface{} `json:"properties"`
|
||||||
|
Geometry geoJSONFeatureGeometry `json:"geometry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoJSONMediaProperties struct {
|
||||||
|
MediaID int `json:"media_id"`
|
||||||
|
MediaTitle string `json:"media_title"`
|
||||||
|
Thumbnail struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
} `json:"thumbnail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoJSONFeatureGeometry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Coordinates [2]float64 `json:"coordinates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGeoJSONFeatureCollection(features []geoJSONFeature) geoJSONFeatureCollection {
|
||||||
|
return geoJSONFeatureCollection{
|
||||||
|
Type: "FeatureCollection",
|
||||||
|
Features: features,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature {
|
||||||
|
return geoJSONFeature{
|
||||||
|
Type: "Feature",
|
||||||
|
Properties: properties,
|
||||||
|
Geometry: geometry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry {
|
||||||
|
coordinates := [2]float64{long, lat}
|
||||||
|
|
||||||
|
return geoJSONFeatureGeometry{
|
||||||
|
Type: "Point",
|
||||||
|
Coordinates: coordinates,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
api "github.com/photoview/photoview/api/graphql"
|
||||||
"github.com/photoview/photoview/api/graphql/auth"
|
"github.com/photoview/photoview/api/graphql/auth"
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
"github.com/photoview/photoview/api/graphql/notification"
|
"github.com/photoview/photoview/api/graphql/notification"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Notification is the resolver for the notification field.
|
||||||
func (r *subscriptionResolver) Notification(ctx context.Context) (<-chan *models.Notification, error) {
|
func (r *subscriptionResolver) Notification(ctx context.Context) (<-chan *models.Notification, error) {
|
||||||
|
|
||||||
user := auth.UserFromContext(ctx)
|
user := auth.UserFromContext(ctx)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, auth.ErrUnauthorized
|
return nil, auth.ErrUnauthorized
|
||||||
@@ -26,3 +31,8 @@ func (r *subscriptionResolver) Notification(ctx context.Context) (<-chan *models
|
|||||||
|
|
||||||
return notificationChannel, nil
|
return notificationChannel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription returns api.SubscriptionResolver implementation.
|
||||||
|
func (r *Resolver) Subscription() api.SubscriptionResolver { return &subscriptionResolver{r} }
|
||||||
|
|
||||||
|
type subscriptionResolver struct{ *Resolver }
|
||||||
|
|||||||
31
api/graphql/resolvers/notification.graphql
Normal file
31
api/graphql/resolvers/notification.graphql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
type Notification {
|
||||||
|
"A key used to identify the notification, new notification updates with the same key, should replace the old notifications"
|
||||||
|
key: String!
|
||||||
|
type: NotificationType!
|
||||||
|
"The text for the title of the notification"
|
||||||
|
header: String!
|
||||||
|
"The text for the body of the notification"
|
||||||
|
content: String!
|
||||||
|
"A value between 0 and 1 when the notification type is `Progress`"
|
||||||
|
progress: Float
|
||||||
|
"Whether or not the message of the notification is positive, the UI might reflect this with a green color"
|
||||||
|
positive: Boolean!
|
||||||
|
"Whether or not the message of the notification is negative, the UI might reflect this with a red color"
|
||||||
|
negative: Boolean!
|
||||||
|
"Time in milliseconds before the notification should close"
|
||||||
|
timeout: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscription {
|
||||||
|
notification: Notification!
|
||||||
|
}
|
||||||
|
|
||||||
|
"Specified the type a particular notification is of"
|
||||||
|
enum NotificationType {
|
||||||
|
"A regular message with no special additions"
|
||||||
|
Message
|
||||||
|
"A notification with an attached progress indicator"
|
||||||
|
Progress
|
||||||
|
"Close a notification with a given key"
|
||||||
|
Close
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package resolvers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
api "github.com/photoview/photoview/api/graphql"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,25 +22,3 @@ func NewRootResolver(db *gorm.DB) Resolver {
|
|||||||
func (r *Resolver) DB(ctx context.Context) *gorm.DB {
|
func (r *Resolver) DB(ctx context.Context) *gorm.DB {
|
||||||
return r.database.WithContext(ctx)
|
return r.database.WithContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) Mutation() api.MutationResolver {
|
|
||||||
return &mutationResolver{r}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) Query() api.QueryResolver {
|
|
||||||
return &queryResolver{r}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) Subscription() api.SubscriptionResolver {
|
|
||||||
return &subscriptionResolver{
|
|
||||||
Resolver: r,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type mutationResolver struct{ *Resolver }
|
|
||||||
|
|
||||||
type queryResolver struct{ *Resolver }
|
|
||||||
|
|
||||||
type subscriptionResolver struct {
|
|
||||||
Resolver *Resolver
|
|
||||||
}
|
|
||||||
|
|||||||
18
api/graphql/resolvers/root.go
Normal file
18
api/graphql/resolvers/root.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
|
import (
|
||||||
|
api "github.com/photoview/photoview/api/graphql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mutation returns api.MutationResolver implementation.
|
||||||
|
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
|
||||||
|
|
||||||
|
// Query returns api.QueryResolver implementation.
|
||||||
|
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
|
||||||
|
|
||||||
|
type mutationResolver struct{ *Resolver }
|
||||||
|
type queryResolver struct{ *Resolver }
|
||||||
47
api/graphql/resolvers/root.graphql
Normal file
47
api/graphql/resolvers/root.graphql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
directive @isAuthorized on FIELD_DEFINITION
|
||||||
|
directive @isAdmin on FIELD_DEFINITION
|
||||||
|
|
||||||
|
scalar Time
|
||||||
|
scalar Any
|
||||||
|
|
||||||
|
"Used to specify which order to sort items in"
|
||||||
|
enum OrderDirection {
|
||||||
|
"Sort accending A-Z"
|
||||||
|
ASC
|
||||||
|
"Sort decending Z-A"
|
||||||
|
DESC
|
||||||
|
}
|
||||||
|
|
||||||
|
"Used to specify pagination on a list of items"
|
||||||
|
input Pagination {
|
||||||
|
"How many items to maximally fetch"
|
||||||
|
limit: Int
|
||||||
|
"How many items to skip from the beginning of the query, specified by the `Ordering`"
|
||||||
|
offset: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
"Used to specify how to sort items"
|
||||||
|
input Ordering {
|
||||||
|
"A column in the database to order by"
|
||||||
|
order_by: String
|
||||||
|
order_direction: OrderDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query
|
||||||
|
|
||||||
|
type Mutation
|
||||||
|
|
||||||
|
"""
|
||||||
|
A group of media from the same album and the same day, that is grouped together in a timeline view
|
||||||
|
NOTE: It isn't used. Just copy from the old schema.graphql.
|
||||||
|
"""
|
||||||
|
type TimelineGroup {
|
||||||
|
"The full album containing the media in this timeline group"
|
||||||
|
album: Album!
|
||||||
|
"The media contained in this timeline group"
|
||||||
|
media: [Media!]!
|
||||||
|
"The total amount of media in this timeline group"
|
||||||
|
mediaTotal: Int!
|
||||||
|
"The day shared for all media in this timeline group"
|
||||||
|
date: Time!
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/database/drivers"
|
"github.com/photoview/photoview/api/database/drivers"
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
"github.com/photoview/photoview/api/scanner/periodic_scanner"
|
"github.com/photoview/photoview/api/scanner/periodic_scanner"
|
||||||
"github.com/photoview/photoview/api/scanner/scanner_queue"
|
"github.com/photoview/photoview/api/scanner/scanner_queue"
|
||||||
"github.com/pkg/errors"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ScanAll is the resolver for the scanAll field.
|
||||||
func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult, error) {
|
func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult, error) {
|
||||||
err := scanner_queue.AddAllToQueue()
|
err := scanner_queue.AddAllToQueue()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,10 +33,11 @@ func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanUser is the resolver for the scanUser field.
|
||||||
func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) {
|
func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) {
|
||||||
var user models.User
|
var user models.User
|
||||||
if err := r.DB(ctx).First(&user, userID).Error; err != nil {
|
if err := r.DB(ctx).First(&user, userID).Error; err != nil {
|
||||||
return nil, errors.Wrap(err, "get user from database")
|
return nil, fmt.Errorf("get user from database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner_queue.AddUserToQueue(&user)
|
scanner_queue.AddUserToQueue(&user)
|
||||||
@@ -43,6 +50,7 @@ func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.Sc
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPeriodicScanInterval is the resolver for the setPeriodicScanInterval field.
|
||||||
func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) {
|
func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) {
|
||||||
db := r.DB(ctx)
|
db := r.DB(ctx)
|
||||||
if interval < 0 {
|
if interval < 0 {
|
||||||
@@ -68,6 +76,7 @@ func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval
|
|||||||
return siteInfo.PeriodicScanInterval, nil
|
return siteInfo.PeriodicScanInterval, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetScannerConcurrentWorkers is the resolver for the setScannerConcurrentWorkers field.
|
||||||
func (r *mutationResolver) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) {
|
func (r *mutationResolver) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) {
|
||||||
db := r.DB(ctx)
|
db := r.DB(ctx)
|
||||||
if workers < 1 {
|
if workers < 1 {
|
||||||
|
|||||||
23
api/graphql/resolvers/scanner.graphql
Normal file
23
api/graphql/resolvers/scanner.graphql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
type ScannerResult {
|
||||||
|
finished: Boolean!
|
||||||
|
success: Boolean!
|
||||||
|
progress: Float
|
||||||
|
message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
"Scan all users for new media"
|
||||||
|
scanAll: ScannerResult! @isAdmin
|
||||||
|
|
||||||
|
"Scan a single user for new media"
|
||||||
|
scanUser(userId: ID!): ScannerResult! @isAdmin
|
||||||
|
|
||||||
|
"""
|
||||||
|
Set how often, in seconds, the server should automatically scan for new media,
|
||||||
|
a value of 0 will disable periodic scans
|
||||||
|
"""
|
||||||
|
setPeriodicScanInterval(interval: Int!): Int! @isAdmin
|
||||||
|
|
||||||
|
"Set max number of concurrent scanner jobs running at once"
|
||||||
|
setScannerConcurrentWorkers(workers: Int!): Int! @isAdmin
|
||||||
|
}
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
directive @isAuthorized on FIELD_DEFINITION
|
|
||||||
directive @isAdmin on FIELD_DEFINITION
|
|
||||||
|
|
||||||
scalar Time
|
|
||||||
scalar Any
|
|
||||||
|
|
||||||
"Used to specify which order to sort items in"
|
|
||||||
enum OrderDirection {
|
|
||||||
"Sort accending A-Z"
|
|
||||||
ASC
|
|
||||||
"Sort decending Z-A"
|
|
||||||
DESC
|
|
||||||
}
|
|
||||||
|
|
||||||
"Used to specify pagination on a list of items"
|
|
||||||
input Pagination {
|
|
||||||
"How many items to maximally fetch"
|
|
||||||
limit: Int
|
|
||||||
"How many items to skip from the beginning of the query, specified by the `Ordering`"
|
|
||||||
offset: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
"Used to specify how to sort items"
|
|
||||||
input Ordering {
|
|
||||||
"A column in the database to order by"
|
|
||||||
order_by: String
|
|
||||||
order_direction: OrderDirection
|
|
||||||
}
|
|
||||||
|
|
||||||
"Credentials used to identify and authenticate a share token"
|
|
||||||
input ShareTokenCredentials {
|
|
||||||
token: String!
|
|
||||||
password: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type Query {
|
|
||||||
siteInfo: SiteInfo!
|
|
||||||
|
|
||||||
"List of registered users, must be admin to call"
|
|
||||||
user(order: Ordering, paginate: Pagination): [User!]! @isAdmin
|
|
||||||
"Information about the currently logged in user"
|
|
||||||
myUser: User! @isAuthorized
|
|
||||||
|
|
||||||
"User preferences for the logged in user"
|
|
||||||
myUserPreferences: UserPreferences! @isAuthorized
|
|
||||||
|
|
||||||
"""
|
|
||||||
Get a list of media, ordered first by day, then by album if multiple media was found for the same day.
|
|
||||||
"""
|
|
||||||
myTimeline(
|
|
||||||
paginate: Pagination,
|
|
||||||
onlyFavorites: Boolean,
|
|
||||||
"Only fetch media that is older than this date"
|
|
||||||
fromDate: Time
|
|
||||||
): [Media!]! @isAuthorized
|
|
||||||
|
|
||||||
"Fetch a share token containing an `Album` or `Media`"
|
|
||||||
shareToken(credentials: ShareTokenCredentials!): ShareToken!
|
|
||||||
"Check if the `ShareToken` credentials are valid"
|
|
||||||
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
|
|
||||||
|
|
||||||
"Perform a search query on the contents of the media library"
|
|
||||||
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mutation {
|
|
||||||
"Authorizes a user and returns a token used to identify the new session"
|
|
||||||
authorizeUser(username: String!, password: String!): AuthorizeResult!
|
|
||||||
|
|
||||||
"Registers the initial user, can only be called if initialSetup from SiteInfo is true"
|
|
||||||
initialSetupWizard(
|
|
||||||
username: String!
|
|
||||||
password: String!
|
|
||||||
rootPath: String!
|
|
||||||
): AuthorizeResult
|
|
||||||
|
|
||||||
"Scan all users for new media"
|
|
||||||
scanAll: ScannerResult! @isAdmin
|
|
||||||
"Scan a single user for new media"
|
|
||||||
scanUser(userId: ID!): ScannerResult! @isAdmin
|
|
||||||
|
|
||||||
"Generate share token for album"
|
|
||||||
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
|
||||||
"Generate share token for media"
|
|
||||||
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
|
||||||
"Delete a share token by it's token value"
|
|
||||||
deleteShareToken(token: String!): ShareToken! @isAuthorized
|
|
||||||
"Set a password for a token, if null is passed for the password argument, the password will be cleared"
|
|
||||||
protectShareToken(token: String!, password: String): ShareToken! @isAuthorized
|
|
||||||
|
|
||||||
"Update a user, fields left as `null` will not be changed"
|
|
||||||
updateUser(
|
|
||||||
id: ID!
|
|
||||||
username: String
|
|
||||||
password: String
|
|
||||||
admin: Boolean
|
|
||||||
): User! @isAdmin
|
|
||||||
"Create a new user"
|
|
||||||
createUser(
|
|
||||||
username: String!
|
|
||||||
password: String
|
|
||||||
admin: Boolean!
|
|
||||||
): User! @isAdmin
|
|
||||||
"Delete an existing user"
|
|
||||||
deleteUser(id: ID!): User! @isAdmin
|
|
||||||
|
|
||||||
"Add a root path from where to look for media for the given user, specified by their user id."
|
|
||||||
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
|
|
||||||
"""
|
|
||||||
Remove a root path from a user, specified by the id of the user and the top album representing the root path.
|
|
||||||
This album was returned when creating the path using `userAddRootPath`.
|
|
||||||
A list of root paths for a particular user can be retrived from the `User.rootAlbums` path.
|
|
||||||
"""
|
|
||||||
userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin
|
|
||||||
|
|
||||||
"""
|
|
||||||
Set how often, in seconds, the server should automatically scan for new media,
|
|
||||||
a value of 0 will disable periodic scans
|
|
||||||
"""
|
|
||||||
setPeriodicScanInterval(interval: Int!): Int! @isAdmin
|
|
||||||
|
|
||||||
"Set max number of concurrent scanner jobs running at once"
|
|
||||||
setScannerConcurrentWorkers(workers: Int!): Int! @isAdmin
|
|
||||||
|
|
||||||
"Set the filter to be used when generating thumbnails"
|
|
||||||
setThumbnailDownsampleMethod(method: ThumbnailFilter!): ThumbnailFilter! @isAdmin
|
|
||||||
|
|
||||||
"Change user preferences for the logged in user"
|
|
||||||
changeUserPreferences(language: String): UserPreferences! @isAuthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
type Subscription {
|
|
||||||
notification: Notification!
|
|
||||||
}
|
|
||||||
|
|
||||||
"Specified the type a particular notification is of"
|
|
||||||
enum NotificationType {
|
|
||||||
"A regular message with no special additions"
|
|
||||||
Message
|
|
||||||
"A notification with an attached progress indicator"
|
|
||||||
Progress
|
|
||||||
"Close a notification with a given key"
|
|
||||||
Close
|
|
||||||
}
|
|
||||||
|
|
||||||
type Notification {
|
|
||||||
"A key used to identify the notification, new notification updates with the same key, should replace the old notifications"
|
|
||||||
key: String!
|
|
||||||
type: NotificationType!
|
|
||||||
"The text for the title of the notification"
|
|
||||||
header: String!
|
|
||||||
"The text for the body of the notification"
|
|
||||||
content: String!
|
|
||||||
"A value between 0 and 1 when the notification type is `Progress`"
|
|
||||||
progress: Float
|
|
||||||
"Whether or not the message of the notification is positive, the UI might reflect this with a green color"
|
|
||||||
positive: Boolean!
|
|
||||||
"Whether or not the message of the notification is negative, the UI might reflect this with a red color"
|
|
||||||
negative: Boolean!
|
|
||||||
"Time in milliseconds before the notification should close"
|
|
||||||
timeout: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthorizeResult {
|
|
||||||
success: Boolean!
|
|
||||||
"A textual status message describing the result, can be used to show an error message when `success` is false"
|
|
||||||
status: String!
|
|
||||||
"An access token used to authenticate new API requests as the newly authorized user. Is present when success is true"
|
|
||||||
token: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScannerResult {
|
|
||||||
finished: Boolean!
|
|
||||||
success: Boolean!
|
|
||||||
progress: Float
|
|
||||||
message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
"A token used to publicly access an album or media"
|
|
||||||
type ShareToken {
|
|
||||||
id: ID!
|
|
||||||
token: String!
|
|
||||||
"The user who created the token"
|
|
||||||
owner: User!
|
|
||||||
"Optional expire date"
|
|
||||||
expire: Time
|
|
||||||
"Whether or not a password is needed to access the share"
|
|
||||||
hasPassword: Boolean!
|
|
||||||
|
|
||||||
"The album this token shares"
|
|
||||||
album: Album
|
|
||||||
"The media this token shares"
|
|
||||||
media: Media
|
|
||||||
}
|
|
||||||
|
|
||||||
"Supported downsampling filters for thumbnail generation"
|
|
||||||
enum ThumbnailFilter {
|
|
||||||
NearestNeighbor,
|
|
||||||
Box,
|
|
||||||
Linear,
|
|
||||||
MitchellNetravali,
|
|
||||||
CatmullRom,
|
|
||||||
Lanczos,
|
|
||||||
}
|
|
||||||
|
|
||||||
"General information about the site"
|
|
||||||
type SiteInfo {
|
|
||||||
"Whether or not the initial setup wizard should be shown"
|
|
||||||
initialSetup: Boolean!
|
|
||||||
"Whether or not face detection is enabled and working"
|
|
||||||
faceDetectionEnabled: Boolean!
|
|
||||||
"How often automatic scans should be initiated in seconds"
|
|
||||||
periodicScanInterval: Int! @isAdmin
|
|
||||||
"How many max concurrent scanner jobs that should run at once"
|
|
||||||
concurrentWorkers: Int! @isAdmin
|
|
||||||
"The filter to use when generating thumbnails"
|
|
||||||
thumbnailMethod: ThumbnailFilter! @isAdmin
|
|
||||||
}
|
|
||||||
|
|
||||||
type User {
|
|
||||||
id: ID!
|
|
||||||
username: String!
|
|
||||||
"All albums owned by this user"
|
|
||||||
albums: [Album!]! @isAdmin
|
|
||||||
"Top level albums owned by this user"
|
|
||||||
rootAlbums: [Album!]! @isAdmin
|
|
||||||
"Whether or not the user has admin privileges"
|
|
||||||
admin: Boolean!
|
|
||||||
}
|
|
||||||
|
|
||||||
"Supported language translations of the user interface"
|
|
||||||
enum LanguageTranslation {
|
|
||||||
English,
|
|
||||||
French,
|
|
||||||
Italian,
|
|
||||||
Swedish,
|
|
||||||
Danish,
|
|
||||||
Spanish,
|
|
||||||
Polish,
|
|
||||||
Ukrainian,
|
|
||||||
German,
|
|
||||||
Russian,
|
|
||||||
TraditionalChinese,
|
|
||||||
SimplifiedChinese,
|
|
||||||
Portuguese,
|
|
||||||
Basque,
|
|
||||||
Turkish
|
|
||||||
}
|
|
||||||
|
|
||||||
"Preferences for regular users"
|
|
||||||
type UserPreferences {
|
|
||||||
id: ID!
|
|
||||||
language: LanguageTranslation
|
|
||||||
}
|
|
||||||
|
|
||||||
type Coordinates {
|
|
||||||
"GPS latitude in degrees"
|
|
||||||
latitude: Float!
|
|
||||||
"GPS longitude in degrees"
|
|
||||||
longitude: Float!
|
|
||||||
}
|
|
||||||
|
|
||||||
"Metadata specific to video media"
|
|
||||||
type VideoMetadata {
|
|
||||||
id: ID!
|
|
||||||
media: Media!
|
|
||||||
width: Int!
|
|
||||||
height: Int!
|
|
||||||
duration: Float!
|
|
||||||
codec: String
|
|
||||||
framerate: Float
|
|
||||||
bitrate: String
|
|
||||||
colorProfile: String
|
|
||||||
audio: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type SearchResult {
|
|
||||||
"The string that was searched for"
|
|
||||||
query: String!
|
|
||||||
"A list of albums that matched the query"
|
|
||||||
albums: [Album!]!
|
|
||||||
"A list of media that matched the query"
|
|
||||||
media: [Media!]!
|
|
||||||
}
|
|
||||||
|
|
||||||
"A group of media from the same album and the same day, that is grouped together in a timeline view"
|
|
||||||
type TimelineGroup {
|
|
||||||
"The full album containing the media in this timeline group"
|
|
||||||
album: Album!
|
|
||||||
"The media contained in this timeline group"
|
|
||||||
media: [Media!]!
|
|
||||||
"The total amount of media in this timeline group"
|
|
||||||
mediaTotal: Int!
|
|
||||||
"The day shared for all media in this timeline group"
|
|
||||||
date: Time!
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/graphql/auth"
|
"github.com/photoview/photoview/api/graphql/auth"
|
||||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Resolver) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
|
// Search is the resolver for the search field.
|
||||||
|
func (r *queryResolver) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
|
||||||
user := auth.UserFromContext(ctx)
|
user := auth.UserFromContext(ctx)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, auth.ErrUnauthorized
|
return nil, auth.ErrUnauthorized
|
||||||
|
|||||||
13
api/graphql/resolvers/search.graphql
Normal file
13
api/graphql/resolvers/search.graphql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
type SearchResult {
|
||||||
|
"The string that was searched for"
|
||||||
|
query: String!
|
||||||
|
"A list of albums that matched the query"
|
||||||
|
albums: [Album!]!
|
||||||
|
"A list of media that matched the query"
|
||||||
|
media: [Media!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
"Perform a search query on the contents of the media library"
|
||||||
|
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
|
||||||
|
}
|
||||||
@@ -1,53 +1,72 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
|
|
||||||
api "github.com/photoview/photoview/api/graphql"
|
api "github.com/photoview/photoview/api/graphql"
|
||||||
"github.com/photoview/photoview/api/graphql/auth"
|
"github.com/photoview/photoview/api/graphql/auth"
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type shareTokenResolver struct {
|
// ShareAlbum is the resolver for the shareAlbum field.
|
||||||
*Resolver
|
func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire *time.Time, password *string) (*models.ShareToken, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, auth.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.AddAlbumShare(r.DB(ctx), user, albumID, expire, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) ShareToken() api.ShareTokenResolver {
|
// ShareMedia is the resolver for the shareMedia field.
|
||||||
return &shareTokenResolver{r}
|
func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *time.Time, password *string) (*models.ShareToken, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, auth.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.AddMediaShare(r.DB(ctx), user, mediaID, expire, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *shareTokenResolver) Owner(ctx context.Context, obj *models.ShareToken) (*models.User, error) {
|
// DeleteShareToken is the resolver for the deleteShareToken field.
|
||||||
return &obj.Owner, nil
|
func (r *mutationResolver) DeleteShareToken(ctx context.Context, token string) (*models.ShareToken, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, auth.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.DeleteShareToken(r.DB(ctx), user.ID, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *shareTokenResolver) Album(ctx context.Context, obj *models.ShareToken) (*models.Album, error) {
|
// ProtectShareToken is the resolver for the protectShareToken field.
|
||||||
return obj.Album, nil
|
func (r *mutationResolver) ProtectShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) {
|
||||||
}
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
func (r *shareTokenResolver) Media(ctx context.Context, obj *models.ShareToken) (*models.Media, error) {
|
return nil, auth.ErrUnauthorized
|
||||||
return obj.Media, nil
|
}
|
||||||
}
|
|
||||||
|
return actions.ProtectShareToken(r.DB(ctx), user.ID, token, password)
|
||||||
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
|
|
||||||
hasPassword := obj.Password != nil
|
|
||||||
return hasPassword, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShareToken is the resolver for the shareToken field.
|
||||||
func (r *queryResolver) ShareToken(ctx context.Context, credentials models.ShareTokenCredentials) (*models.ShareToken, error) {
|
func (r *queryResolver) ShareToken(ctx context.Context, credentials models.ShareTokenCredentials) (*models.ShareToken, error) {
|
||||||
|
|
||||||
var token models.ShareToken
|
var token models.ShareToken
|
||||||
if err := r.DB(ctx).Preload(clause.Associations).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
|
if err := r.DB(ctx).Preload(clause.Associations).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, errors.New("share not found")
|
return nil, errors.New("share not found")
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.Wrap(err, "failed to get share token from database")
|
return nil, fmt.Errorf("failed to get share token from database: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +75,7 @@ func (r *queryResolver) ShareToken(ctx context.Context, credentials models.Share
|
|||||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||||
return nil, errors.New("unauthorized")
|
return nil, errors.New("unauthorized")
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.Wrap(err, "failed to compare token password hashes")
|
return nil, fmt.Errorf("failed to compare token password hashes: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,14 +83,14 @@ func (r *queryResolver) ShareToken(ctx context.Context, credentials models.Share
|
|||||||
return &token, nil
|
return &token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShareTokenValidatePassword is the resolver for the shareTokenValidatePassword field.
|
||||||
func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, credentials models.ShareTokenCredentials) (bool, error) {
|
func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, credentials models.ShareTokenCredentials) (bool, error) {
|
||||||
|
|
||||||
var token models.ShareToken
|
var token models.ShareToken
|
||||||
if err := r.DB(ctx).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
|
if err := r.DB(ctx).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return false, errors.New("share not found")
|
return false, errors.New("share not found")
|
||||||
} else {
|
} else {
|
||||||
return false, errors.Wrap(err, "failed to get share token from database")
|
return false, fmt.Errorf("failed to get share token from database: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,50 +106,20 @@ func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, credenti
|
|||||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||||
return false, nil
|
return false, nil
|
||||||
} else {
|
} else {
|
||||||
return false, errors.Wrap(err, "could not compare token password hashes")
|
return false, fmt.Errorf("could not compare token password hashes: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire *time.Time,
|
// HasPassword is the resolver for the hasPassword field.
|
||||||
password *string) (*models.ShareToken, error) {
|
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
|
||||||
|
hasPassword := obj.Password != nil
|
||||||
user := auth.UserFromContext(ctx)
|
return hasPassword, nil
|
||||||
if user == nil {
|
|
||||||
return nil, auth.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions.AddAlbumShare(r.DB(ctx), user, albumID, expire, password)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *time.Time,
|
// ShareToken returns api.ShareTokenResolver implementation.
|
||||||
password *string) (*models.ShareToken, error) {
|
func (r *Resolver) ShareToken() api.ShareTokenResolver { return &shareTokenResolver{r} }
|
||||||
|
|
||||||
user := auth.UserFromContext(ctx)
|
type shareTokenResolver struct{ *Resolver }
|
||||||
if user == nil {
|
|
||||||
return nil, auth.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions.AddMediaShare(r.DB(ctx), user, mediaID, expire, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mutationResolver) DeleteShareToken(ctx context.Context, tokenValue string) (*models.ShareToken, error) {
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, auth.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions.DeleteShareToken(r.DB(ctx), user.ID, tokenValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mutationResolver) ProtectShareToken(ctx context.Context, tokenValue string, password *string) (*models.ShareToken, error) {
|
|
||||||
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, auth.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions.ProtectShareToken(r.DB(ctx), user.ID, tokenValue, password)
|
|
||||||
}
|
|
||||||
|
|||||||
44
api/graphql/resolvers/share_token.graphql
Normal file
44
api/graphql/resolvers/share_token.graphql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"Credentials used to identify and authenticate a share token"
|
||||||
|
input ShareTokenCredentials {
|
||||||
|
token: String!
|
||||||
|
password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
"A token used to publicly access an album or media"
|
||||||
|
type ShareToken {
|
||||||
|
id: ID!
|
||||||
|
token: String!
|
||||||
|
"The user who created the token"
|
||||||
|
owner: User!
|
||||||
|
"Optional expire date"
|
||||||
|
expire: Time
|
||||||
|
"Whether or not a password is needed to access the share"
|
||||||
|
hasPassword: Boolean!
|
||||||
|
|
||||||
|
"The album this token shares"
|
||||||
|
album: Album
|
||||||
|
"The media this token shares"
|
||||||
|
media: Media
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
"Fetch a share token containing an `Album` or `Media`"
|
||||||
|
shareToken(credentials: ShareTokenCredentials!): ShareToken!
|
||||||
|
|
||||||
|
"Check if the `ShareToken` credentials are valid"
|
||||||
|
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
"Generate share token for album"
|
||||||
|
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
||||||
|
|
||||||
|
"Generate share token for media"
|
||||||
|
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
||||||
|
|
||||||
|
"Delete a share token by it's token value"
|
||||||
|
deleteShareToken(token: String!): ShareToken! @isAuthorized
|
||||||
|
|
||||||
|
"Set a password for a token, if null is passed for the password argument, the password will be cleared"
|
||||||
|
protectShareToken(token: String!, password: String): ShareToken! @isAuthorized
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
@@ -8,18 +12,17 @@ import (
|
|||||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SiteInfo is the resolver for the siteInfo field.
|
||||||
func (r *queryResolver) SiteInfo(ctx context.Context) (*models.SiteInfo, error) {
|
func (r *queryResolver) SiteInfo(ctx context.Context) (*models.SiteInfo, error) {
|
||||||
return models.GetSiteInfo(r.DB(ctx))
|
return models.GetSiteInfo(r.DB(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteInfoResolver struct {
|
// FaceDetectionEnabled is the resolver for the faceDetectionEnabled field.
|
||||||
*Resolver
|
func (r *siteInfoResolver) FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) {
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) SiteInfo() api.SiteInfoResolver {
|
|
||||||
return &SiteInfoResolver{r}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (SiteInfoResolver) FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) {
|
|
||||||
return face_detection.GlobalFaceDetector != nil, nil
|
return face_detection.GlobalFaceDetector != nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteInfo returns api.SiteInfoResolver implementation.
|
||||||
|
func (r *Resolver) SiteInfo() api.SiteInfoResolver { return &siteInfoResolver{r} }
|
||||||
|
|
||||||
|
type siteInfoResolver struct{ *Resolver }
|
||||||
|
|||||||
17
api/graphql/resolvers/site_info.graphql
Normal file
17
api/graphql/resolvers/site_info.graphql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"General information about the site"
|
||||||
|
type SiteInfo {
|
||||||
|
"Whether or not the initial setup wizard should be shown"
|
||||||
|
initialSetup: Boolean!
|
||||||
|
"Whether or not face detection is enabled and working"
|
||||||
|
faceDetectionEnabled: Boolean!
|
||||||
|
"How often automatic scans should be initiated in seconds"
|
||||||
|
periodicScanInterval: Int! @isAdmin
|
||||||
|
"How many max concurrent scanner jobs that should run at once"
|
||||||
|
concurrentWorkers: Int! @isAdmin
|
||||||
|
"The filter to use when generating thumbnails"
|
||||||
|
thumbnailMethod: ThumbnailFilter! @isAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
siteInfo: SiteInfo!
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
// "github.com/pkg/errors"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context,
|
// SetThumbnailDownsampleMethod is the resolver for the setThumbnailDownsampleMethod field.
|
||||||
method models.ThumbnailFilter) (models.ThumbnailFilter, error) {
|
func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context, method models.ThumbnailFilter) (models.ThumbnailFilter, error) {
|
||||||
|
|
||||||
db := r.DB(ctx)
|
db := r.DB(ctx)
|
||||||
|
|
||||||
// if method > 5 {
|
// if method > 5 {
|
||||||
@@ -38,5 +40,4 @@ func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context,
|
|||||||
// lng := models.LanguageTranslation(*language)
|
// lng := models.LanguageTranslation(*language)
|
||||||
// langTrans = &lng
|
// langTrans = &lng
|
||||||
// }
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
api/graphql/resolvers/thumbnails.graphql
Normal file
14
api/graphql/resolvers/thumbnails.graphql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"Supported downsampling filters for thumbnail generation"
|
||||||
|
enum ThumbnailFilter {
|
||||||
|
NearestNeighbor,
|
||||||
|
Box,
|
||||||
|
Linear,
|
||||||
|
MitchellNetravali,
|
||||||
|
CatmullRom,
|
||||||
|
Lanczos,
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
"Set the filter to be used when generating thumbnails"
|
||||||
|
setThumbnailDownsampleMethod(method: ThumbnailFilter!): ThumbnailFilter! @isAdmin
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,9 +13,8 @@ import (
|
|||||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool,
|
// MyTimeline is the resolver for the myTimeline field.
|
||||||
fromDate *time.Time) ([]*models.Media, error) {
|
func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) {
|
||||||
|
|
||||||
user := auth.UserFromContext(ctx)
|
user := auth.UserFromContext(ctx)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, auth.ErrUnauthorized
|
return nil, auth.ErrUnauthorized
|
||||||
|
|||||||
11
api/graphql/resolvers/timeline.graphql
Normal file
11
api/graphql/resolvers/timeline.graphql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
extend type Query {
|
||||||
|
"""
|
||||||
|
Get a list of media, ordered first by day, then by album if multiple media was found for the same day.
|
||||||
|
"""
|
||||||
|
myTimeline(
|
||||||
|
paginate: Pagination,
|
||||||
|
onlyFavorites: Boolean,
|
||||||
|
"Only fetch media that is older than this date"
|
||||||
|
fromDate: Time
|
||||||
|
): [Media!]! @isAuthorized
|
||||||
|
}
|
||||||
@@ -1,80 +1,26 @@
|
|||||||
package resolvers
|
package resolvers
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
api "github.com/photoview/photoview/api/graphql"
|
api "github.com/photoview/photoview/api/graphql"
|
||||||
"github.com/photoview/photoview/api/graphql/auth"
|
"github.com/photoview/photoview/api/graphql/auth"
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||||
"github.com/photoview/photoview/api/scanner"
|
"github.com/photoview/photoview/api/scanner"
|
||||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
|
||||||
"github.com/photoview/photoview/api/utils"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type userResolver struct {
|
// AuthorizeUser is the resolver for the authorizeUser field.
|
||||||
*Resolver
|
func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string, password string) (*models.AuthorizeResult, error) {
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) User() api.UserResolver {
|
|
||||||
return &userResolver{r}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *queryResolver) User(ctx context.Context, order *models.Ordering,
|
|
||||||
paginate *models.Pagination) ([]*models.User, error) {
|
|
||||||
|
|
||||||
var users []*models.User
|
|
||||||
|
|
||||||
if err := models.FormatSQL(r.DB(ctx).Model(models.User{}), order, paginate).Find(&users).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return users, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userResolver) Albums(ctx context.Context, user *models.User) ([]*models.Album, error) {
|
|
||||||
user.FillAlbums(r.DB(ctx))
|
|
||||||
|
|
||||||
pointerAlbums := make([]*models.Album, len(user.Albums))
|
|
||||||
for i, album := range user.Albums {
|
|
||||||
pointerAlbums[i] = &album
|
|
||||||
}
|
|
||||||
|
|
||||||
return pointerAlbums, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userResolver) RootAlbums(ctx context.Context, user *models.User) (albums []*models.Album, err error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
|
|
||||||
err = db.Model(&user).
|
|
||||||
Where("albums.parent_album_id NOT IN (?)",
|
|
||||||
db.Table("user_albums").
|
|
||||||
Select("albums.id").
|
|
||||||
Joins("JOIN albums ON albums.id = user_albums.album_id AND user_albums.user_id = ?", user.ID),
|
|
||||||
).Or("albums.parent_album_id IS NULL").Order("path ASC").
|
|
||||||
Association("Albums").Find(&albums)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) {
|
|
||||||
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, auth.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string,
|
|
||||||
password string) (*models.AuthorizeResult, error) {
|
|
||||||
db := r.DB(ctx)
|
db := r.DB(ctx)
|
||||||
user, err := models.AuthorizeUser(db, username, password)
|
user, err := models.AuthorizeUser(db, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,8 +52,8 @@ func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username string, password string,
|
// InitialSetupWizard is the resolver for the initialSetupWizard field.
|
||||||
rootPath string) (*models.AuthorizeResult, error) {
|
func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) {
|
||||||
db := r.DB(ctx)
|
db := r.DB(ctx)
|
||||||
siteInfo, err := models.GetSiteInfo(db)
|
siteInfo, err := models.GetSiteInfo(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -159,53 +105,8 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *queryResolver) MyUserPreferences(ctx context.Context) (*models.UserPreferences, error) {
|
// UpdateUser is the resolver for the updateUser field.
|
||||||
user := auth.UserFromContext(ctx)
|
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string, admin *bool) (*models.User, error) {
|
||||||
if user == nil {
|
|
||||||
return nil, auth.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
userPref := models.UserPreferences{
|
|
||||||
UserID: user.ID,
|
|
||||||
}
|
|
||||||
if err := r.DB(ctx).Where("user_id = ?", user.ID).FirstOrCreate(&userPref).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &userPref, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mutationResolver) ChangeUserPreferences(ctx context.Context, language *string) (*models.UserPreferences, error) {
|
|
||||||
db := r.DB(ctx)
|
|
||||||
user := auth.UserFromContext(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return nil, auth.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
var langTrans *models.LanguageTranslation = nil
|
|
||||||
if language != nil {
|
|
||||||
lng := models.LanguageTranslation(*language)
|
|
||||||
langTrans = &lng
|
|
||||||
}
|
|
||||||
|
|
||||||
var userPref models.UserPreferences
|
|
||||||
if err := db.Where("user_id = ?", user.ID).FirstOrInit(&userPref).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userPref.UserID = user.ID
|
|
||||||
userPref.Language = langTrans
|
|
||||||
|
|
||||||
if err := db.Save(&userPref).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &userPref, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin queries
|
|
||||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string,
|
|
||||||
admin *bool) (*models.User, error) {
|
|
||||||
db := r.DB(ctx)
|
db := r.DB(ctx)
|
||||||
|
|
||||||
if username == nil && password == nil && admin == nil {
|
if username == nil && password == nil && admin == nil {
|
||||||
@@ -236,15 +137,14 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Save(&user).Error; err != nil {
|
if err := db.Save(&user).Error; err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to update user")
|
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string,
|
// CreateUser is the resolver for the createUser field.
|
||||||
admin bool) (*models.User, error) {
|
func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string, admin bool) (*models.User, error) {
|
||||||
|
|
||||||
var user *models.User
|
var user *models.User
|
||||||
|
|
||||||
transactionError := r.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
transactionError := r.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -264,10 +164,12 @@ func (r *mutationResolver) CreateUser(ctx context.Context, username string, pass
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUser is the resolver for the deleteUser field.
|
||||||
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
|
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
|
||||||
return actions.DeleteUser(r.DB(ctx), id)
|
return actions.DeleteUser(r.DB(ctx), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserAddRootPath is the resolver for the userAddRootPath field.
|
||||||
func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath string) (*models.Album, error) {
|
func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath string) (*models.Album, error) {
|
||||||
db := r.DB(ctx)
|
db := r.DB(ctx)
|
||||||
|
|
||||||
@@ -286,6 +188,7 @@ func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath
|
|||||||
return newAlbum, nil
|
return newAlbum, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserRemoveRootAlbum is the resolver for the userRemoveRootAlbum field.
|
||||||
func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) {
|
func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) {
|
||||||
db := r.DB(ctx)
|
db := r.DB(ctx)
|
||||||
|
|
||||||
@@ -339,44 +242,101 @@ func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int,
|
|||||||
return &album, nil
|
return &album, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanup(tx *gorm.DB, albumID int, childAlbumIDs []int) ([]int, error) {
|
// ChangeUserPreferences is the resolver for the changeUserPreferences field.
|
||||||
var userAlbumCount int
|
func (r *mutationResolver) ChangeUserPreferences(ctx context.Context, language *string) (*models.UserPreferences, error) {
|
||||||
var deletedAlbumIDs []int = nil
|
db := r.DB(ctx)
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, auth.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?",
|
var langTrans *models.LanguageTranslation = nil
|
||||||
albumID).Scan(&userAlbumCount).Error; err != nil {
|
if language != nil {
|
||||||
|
lng := models.LanguageTranslation(*language)
|
||||||
|
langTrans = &lng
|
||||||
|
}
|
||||||
|
|
||||||
|
var userPref models.UserPreferences
|
||||||
|
if err := db.Where("user_id = ?", user.ID).FirstOrInit(&userPref).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if userAlbumCount == 0 {
|
userPref.UserID = user.ID
|
||||||
deletedAlbumIDs = append(childAlbumIDs, albumID)
|
userPref.Language = langTrans
|
||||||
childAlbumIDs = nil
|
|
||||||
// Delete albums from database
|
if err := db.Save(&userPref).Error; err != nil {
|
||||||
if err := tx.Delete(&models.Album{}, "id IN (?)", deletedAlbumIDs).Error; err != nil {
|
return nil, err
|
||||||
deletedAlbumIDs = nil
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return deletedAlbumIDs, nil
|
|
||||||
|
return &userPref, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearCacheAndReloadFaces(db *gorm.DB, deletedAlbumIDs []int) error {
|
// User is the resolver for the user field.
|
||||||
if deletedAlbumIDs != nil {
|
func (r *queryResolver) User(ctx context.Context, order *models.Ordering, paginate *models.Pagination) ([]*models.User, error) {
|
||||||
// Delete albums from cache
|
var users []*models.User
|
||||||
for _, id := range deletedAlbumIDs {
|
|
||||||
cacheAlbumPath := path.Join(utils.MediaCachePath(), strconv.Itoa(id))
|
|
||||||
|
|
||||||
if err := os.RemoveAll(cacheAlbumPath); err != nil {
|
if err := models.FormatSQL(r.DB(ctx).Model(models.User{}), order, paginate).Find(&users).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reload faces as media might have been deleted
|
|
||||||
if face_detection.GlobalFaceDetector != nil {
|
|
||||||
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MyUser is the resolver for the myUser field.
|
||||||
|
func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, auth.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyUserPreferences is the resolver for the myUserPreferences field.
|
||||||
|
func (r *queryResolver) MyUserPreferences(ctx context.Context) (*models.UserPreferences, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, auth.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
userPref := models.UserPreferences{
|
||||||
|
UserID: user.ID,
|
||||||
|
}
|
||||||
|
if err := r.DB(ctx).Where("user_id = ?", user.ID).FirstOrCreate(&userPref).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &userPref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums is the resolver for the albums field.
|
||||||
|
func (r *userResolver) Albums(ctx context.Context, obj *models.User) ([]*models.Album, error) {
|
||||||
|
obj.FillAlbums(r.DB(ctx))
|
||||||
|
|
||||||
|
pointerAlbums := make([]*models.Album, len(obj.Albums))
|
||||||
|
for i, album := range obj.Albums {
|
||||||
|
pointerAlbums[i] = &album
|
||||||
|
}
|
||||||
|
|
||||||
|
return pointerAlbums, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootAlbums is the resolver for the rootAlbums field.
|
||||||
|
func (r *userResolver) RootAlbums(ctx context.Context, obj *models.User) (albums []*models.Album, err error) {
|
||||||
|
db := r.DB(ctx)
|
||||||
|
|
||||||
|
err = db.Model(obj).
|
||||||
|
Where("albums.parent_album_id NOT IN (?)",
|
||||||
|
db.Table("user_albums").
|
||||||
|
Select("albums.id").
|
||||||
|
Joins("JOIN albums ON albums.id = user_albums.album_id AND user_albums.user_id = ?", obj.ID),
|
||||||
|
).Or("albums.parent_album_id IS NULL").Order("path ASC").
|
||||||
|
Association("Albums").Find(&albums)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// User returns api.UserResolver implementation.
|
||||||
|
func (r *Resolver) User() api.UserResolver { return &userResolver{r} }
|
||||||
|
|
||||||
|
type userResolver struct{ *Resolver }
|
||||||
|
|||||||
97
api/graphql/resolvers/user.graphql
Normal file
97
api/graphql/resolvers/user.graphql
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
username: String!
|
||||||
|
"All albums owned by this user"
|
||||||
|
albums: [Album!]! @isAdmin
|
||||||
|
"Top level albums owned by this user"
|
||||||
|
rootAlbums: [Album!]! @isAdmin
|
||||||
|
"Whether or not the user has admin privileges"
|
||||||
|
admin: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
"Supported language translations of the user interface"
|
||||||
|
enum LanguageTranslation {
|
||||||
|
English,
|
||||||
|
French,
|
||||||
|
Italian,
|
||||||
|
Swedish,
|
||||||
|
Danish,
|
||||||
|
Spanish,
|
||||||
|
Polish,
|
||||||
|
Ukrainian,
|
||||||
|
German,
|
||||||
|
Russian,
|
||||||
|
TraditionalChinese,
|
||||||
|
SimplifiedChinese,
|
||||||
|
Portuguese,
|
||||||
|
Basque,
|
||||||
|
Turkish
|
||||||
|
}
|
||||||
|
|
||||||
|
"Preferences for regular users"
|
||||||
|
type UserPreferences {
|
||||||
|
id: ID!
|
||||||
|
language: LanguageTranslation
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizeResult {
|
||||||
|
success: Boolean!
|
||||||
|
|
||||||
|
"A textual status message describing the result, can be used to show an error message when `success` is false"
|
||||||
|
status: String!
|
||||||
|
"An access token used to authenticate new API requests as the newly authorized user. Is present when success is true"
|
||||||
|
token: String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
"List of registered users, must be admin to call"
|
||||||
|
user(order: Ordering, paginate: Pagination): [User!]! @isAdmin
|
||||||
|
|
||||||
|
"Information about the currently logged in user"
|
||||||
|
myUser: User! @isAuthorized
|
||||||
|
|
||||||
|
"User preferences for the logged in user"
|
||||||
|
myUserPreferences: UserPreferences! @isAuthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
"Authorizes a user and returns a token used to identify the new session"
|
||||||
|
authorizeUser(username: String!, password: String!): AuthorizeResult!
|
||||||
|
|
||||||
|
"Registers the initial user, can only be called if initialSetup from SiteInfo is true"
|
||||||
|
initialSetupWizard(
|
||||||
|
username: String!
|
||||||
|
password: String!
|
||||||
|
rootPath: String!
|
||||||
|
): AuthorizeResult
|
||||||
|
|
||||||
|
"Update a user, fields left as `null` will not be changed"
|
||||||
|
updateUser(
|
||||||
|
id: ID!
|
||||||
|
username: String
|
||||||
|
password: String
|
||||||
|
admin: Boolean
|
||||||
|
): User! @isAdmin
|
||||||
|
"Create a new user"
|
||||||
|
createUser(
|
||||||
|
username: String!
|
||||||
|
password: String
|
||||||
|
admin: Boolean!
|
||||||
|
): User! @isAdmin
|
||||||
|
"Delete an existing user"
|
||||||
|
deleteUser(id: ID!): User! @isAdmin
|
||||||
|
|
||||||
|
"Add a root path from where to look for media for the given user, specified by their user id."
|
||||||
|
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
|
||||||
|
|
||||||
|
"""
|
||||||
|
Remove a root path from a user, specified by the id of the user and the top album representing the root path.
|
||||||
|
This album was returned when creating the path using `userAddRootPath`.
|
||||||
|
A list of root paths for a particular user can be retrived from the `User.rootAlbums` path.
|
||||||
|
"""
|
||||||
|
userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin
|
||||||
|
|
||||||
|
"Change user preferences for the logged in user"
|
||||||
|
changeUserPreferences(language: String): UserPreferences! @isAuthorized
|
||||||
|
}
|
||||||
54
api/graphql/resolvers/user.util.go
Normal file
54
api/graphql/resolvers/user.util.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package resolvers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||||
|
"github.com/photoview/photoview/api/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cleanup(tx *gorm.DB, albumID int, childAlbumIDs []int) ([]int, error) {
|
||||||
|
var userAlbumCount int
|
||||||
|
var deletedAlbumIDs []int = nil
|
||||||
|
|
||||||
|
if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?",
|
||||||
|
albumID).Scan(&userAlbumCount).Error; err != nil {
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if userAlbumCount == 0 {
|
||||||
|
deletedAlbumIDs = append(childAlbumIDs, albumID)
|
||||||
|
childAlbumIDs = nil
|
||||||
|
// Delete albums from database
|
||||||
|
if err := tx.Delete(&models.Album{}, "id IN (?)", deletedAlbumIDs).Error; err != nil {
|
||||||
|
deletedAlbumIDs = nil
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deletedAlbumIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCacheAndReloadFaces(db *gorm.DB, deletedAlbumIDs []int) error {
|
||||||
|
if deletedAlbumIDs != nil {
|
||||||
|
// Delete albums from cache
|
||||||
|
for _, id := range deletedAlbumIDs {
|
||||||
|
cacheAlbumPath := path.Join(utils.MediaCachePath(), strconv.Itoa(id))
|
||||||
|
|
||||||
|
if err := os.RemoveAll(cacheAlbumPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reload faces as media might have been deleted
|
||||||
|
if face_detection.GlobalFaceDetector != nil {
|
||||||
|
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
13
scripts/test_all.sh
Executable file
13
scripts/test_all.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
for test in $(dirname $0)/test_*
|
||||||
|
do
|
||||||
|
if [ "${test}" != "${test%%/scripts/test_all.sh}" ]
|
||||||
|
then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo Running ${test}...
|
||||||
|
${test}
|
||||||
|
done
|
||||||
5
scripts/test_api_coverage.sh
Executable file
5
scripts/test_api_coverage.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd $(dirname $0)/../api
|
||||||
|
go test ./... -v -database -filesystem -p 1 -coverprofile=coverage.txt -covermode=atomic
|
||||||
11
scripts/test_is_generated_code_in_sync.sh
Executable file
11
scripts/test_is_generated_code_in_sync.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd $(dirname $0)/../api
|
||||||
|
go generate ./...
|
||||||
|
if [ "$(git status -s 2>/dev/null | head -1)" != "" ]; then
|
||||||
|
echo '--- FAIL: The generated API code is out of sync with the recent changes. Please run `go generate ./...` under `./api` to regenerate it and commit it to this branch.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo '--- PASS: All generated code is in sync with the project.'
|
||||||
Reference in New Issue
Block a user