mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
File storage rewrite (#2676)
* Restructure data layer part 2 (#2599) * Refactor and separate image model * Refactor image query builder * Handle relationships in image query builder * Remove relationship management methods * Refactor gallery model/query builder * Add scenes to gallery model * Convert scene model * Refactor scene models * Remove unused methods * Add unit tests for gallery * Add image tests * Add scene tests * Convert unnecessary scene value pointers to values * Convert unnecessary pointer values to values * Refactor scene partial * Add scene partial tests * Refactor ImagePartial * Add image partial tests * Refactor gallery partial update * Add partial gallery update tests * Use zero/null package for null values * Add files and scan system * Add sqlite implementation for files/folders * Add unit tests for files/folders * Image refactors * Update image data layer * Refactor gallery model and creation * Refactor scene model * Refactor scenes * Don't set title from filename * Allow galleries to freely add/remove images * Add multiple scene file support to graphql and UI * Add multiple file support for images in graphql/UI * Add multiple file for galleries in graphql/UI * Remove use of some deprecated fields * Remove scene path usage * Remove gallery path usage * Remove path from image * Move funscript to video file * Refactor caption detection * Migrate existing data * Add post commit/rollback hook system * Lint. Comment out import/export tests * Add WithDatabase read only wrapper * Prepend tasks to list * Add 32 pre-migration * Add warnings in release and migration notes
This commit is contained in:
@@ -2,199 +2,390 @@ package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/paths"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/intslice"
|
||||
)
|
||||
|
||||
const mutexType = "image"
|
||||
var (
|
||||
ErrNotImageFile = errors.New("not an image file")
|
||||
)
|
||||
|
||||
// const mutexType = "image"
|
||||
|
||||
type FinderCreatorUpdater interface {
|
||||
FindByChecksum(ctx context.Context, checksum string) (*models.Image, error)
|
||||
Create(ctx context.Context, newImage models.Image) (*models.Image, error)
|
||||
UpdateFull(ctx context.Context, updatedImage models.Image) (*models.Image, error)
|
||||
Update(ctx context.Context, updatedImage models.ImagePartial) (*models.Image, error)
|
||||
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Image, error)
|
||||
FindByFingerprints(ctx context.Context, fp []file.Fingerprint) ([]*models.Image, error)
|
||||
Create(ctx context.Context, newImage *models.ImageCreateInput) error
|
||||
Update(ctx context.Context, updatedImage *models.Image) error
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
file.Scanner
|
||||
|
||||
StripFileExtension bool
|
||||
|
||||
CaseSensitiveFs bool
|
||||
TxnManager txn.Manager
|
||||
CreatorUpdater FinderCreatorUpdater
|
||||
Paths *paths.Paths
|
||||
PluginCache *plugin.Cache
|
||||
MutexManager *utils.MutexManager
|
||||
type GalleryFinderCreator interface {
|
||||
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Gallery, error)
|
||||
FindByFolderID(ctx context.Context, folderID file.FolderID) ([]*models.Gallery, error)
|
||||
Create(ctx context.Context, newObject *models.Gallery, fileIDs []file.ID) error
|
||||
}
|
||||
|
||||
func FileScanner(hasher file.Hasher) file.Scanner {
|
||||
return file.Scanner{
|
||||
Hasher: hasher,
|
||||
CalculateMD5: true,
|
||||
type ScanConfig interface {
|
||||
GetCreateGalleriesFromFolders() bool
|
||||
IsGenerateThumbnails() bool
|
||||
}
|
||||
|
||||
type ScanHandler struct {
|
||||
CreatorUpdater FinderCreatorUpdater
|
||||
GalleryFinder GalleryFinderCreator
|
||||
|
||||
ThumbnailGenerator ThumbnailGenerator
|
||||
|
||||
ScanConfig ScanConfig
|
||||
|
||||
PluginCache *plugin.Cache
|
||||
}
|
||||
|
||||
func (h *ScanHandler) validate() error {
|
||||
if h.CreatorUpdater == nil {
|
||||
return errors.New("CreatorUpdater is required")
|
||||
}
|
||||
if h.GalleryFinder == nil {
|
||||
return errors.New("GalleryFinder is required")
|
||||
}
|
||||
if h.ScanConfig == nil {
|
||||
return errors.New("ScanConfig is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scanner *Scanner) ScanExisting(ctx context.Context, existing file.FileBased, file file.SourceFile) (retImage *models.Image, err error) {
|
||||
scanned, err := scanner.Scanner.ScanExisting(existing, file)
|
||||
func (h *ScanHandler) Handle(ctx context.Context, f file.File) error {
|
||||
if err := h.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imageFile, ok := f.(*file.ImageFile)
|
||||
if !ok {
|
||||
return ErrNotImageFile
|
||||
}
|
||||
|
||||
// try to match the file to an image
|
||||
existing, err := h.CreatorUpdater.FindByFileID(ctx, imageFile.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return fmt.Errorf("finding existing image: %w", err)
|
||||
}
|
||||
|
||||
i := existing.(*models.Image)
|
||||
if len(existing) == 0 {
|
||||
// try also to match file by fingerprints
|
||||
existing, err = h.CreatorUpdater.FindByFingerprints(ctx, imageFile.Fingerprints)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding existing image by fingerprints: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
path := scanned.New.Path
|
||||
oldChecksum := i.Checksum
|
||||
changed := false
|
||||
|
||||
if scanned.ContentsChanged() {
|
||||
logger.Infof("%s has been updated: rescanning", path)
|
||||
|
||||
// regenerate the file details as well
|
||||
if err := SetFileDetails(i); err != nil {
|
||||
return nil, err
|
||||
if len(existing) > 0 {
|
||||
if err := h.associateExisting(ctx, existing, imageFile); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// create a new image
|
||||
now := time.Now()
|
||||
newImage := &models.Image{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
changed = true
|
||||
} else if scanned.FileUpdated() {
|
||||
logger.Infof("Updated image file %s", path)
|
||||
// if the file is in a zip, then associate it with the gallery
|
||||
if imageFile.ZipFileID != nil {
|
||||
g, err := h.GalleryFinder.FindByFileID(ctx, *imageFile.ZipFileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding gallery for zip file id %d: %w", *imageFile.ZipFileID, err)
|
||||
}
|
||||
|
||||
changed = true
|
||||
for _, gg := range g {
|
||||
newImage.GalleryIDs = append(newImage.GalleryIDs, gg.ID)
|
||||
}
|
||||
} else if h.ScanConfig.GetCreateGalleriesFromFolders() {
|
||||
if err := h.associateFolderBasedGallery(ctx, newImage, imageFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.CreatorUpdater.Create(ctx, &models.ImageCreateInput{
|
||||
Image: newImage,
|
||||
FileIDs: []file.ID{imageFile.ID},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("creating new image: %w", err)
|
||||
}
|
||||
|
||||
h.PluginCache.ExecutePostHooks(ctx, newImage.ID, plugin.ImageCreatePost, nil, nil)
|
||||
|
||||
existing = []*models.Image{newImage}
|
||||
}
|
||||
|
||||
if changed {
|
||||
i.SetFile(*scanned.New)
|
||||
i.UpdatedAt = models.SQLiteTimestamp{Timestamp: time.Now()}
|
||||
if h.ScanConfig.IsGenerateThumbnails() {
|
||||
for _, s := range existing {
|
||||
if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil {
|
||||
// just log if cover generation fails. We can try again on rescan
|
||||
logger.Errorf("Error generating thumbnail for %s: %v", imageFile.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we are operating on a checksum now, so grab a mutex on the checksum
|
||||
done := make(chan struct{})
|
||||
scanner.MutexManager.Claim(mutexType, scanned.New.Checksum, done)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
||||
// free the mutex once transaction is complete
|
||||
defer close(done)
|
||||
var err error
|
||||
func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *file.ImageFile) error {
|
||||
for _, i := range existing {
|
||||
found := false
|
||||
for _, sf := range i.Files {
|
||||
if sf.ID == f.Base().ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ensure no clashes of hashes
|
||||
if scanned.New.Checksum != "" && scanned.Old.Checksum != scanned.New.Checksum {
|
||||
dupe, _ := scanner.CreatorUpdater.FindByChecksum(ctx, i.Checksum)
|
||||
if dupe != nil {
|
||||
return fmt.Errorf("MD5 for file %s is the same as that of %s", path, dupe.Path)
|
||||
if !found {
|
||||
logger.Infof("Adding %s to image %s", f.Path, i.GetTitle())
|
||||
i.Files = append(i.Files, f)
|
||||
|
||||
// associate with folder-based gallery if applicable
|
||||
if h.ScanConfig.GetCreateGalleriesFromFolders() {
|
||||
if err := h.associateFolderBasedGallery(ctx, i, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
retImage, err = scanner.CreatorUpdater.UpdateFull(ctx, *i)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// remove the old thumbnail if the checksum changed - we'll regenerate it
|
||||
if oldChecksum != scanned.New.Checksum {
|
||||
// remove cache dir of gallery
|
||||
err = os.Remove(scanner.Paths.Generated.GetThumbnailPath(oldChecksum, models.DefaultGthumbWidth))
|
||||
if err != nil {
|
||||
logger.Errorf("Error deleting thumbnail image: %s", err)
|
||||
if err := h.CreatorUpdater.Update(ctx, i); err != nil {
|
||||
return fmt.Errorf("updating image: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, retImage.ID, plugin.ImageUpdatePost, nil, nil)
|
||||
}
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scanner *Scanner) ScanNew(ctx context.Context, f file.SourceFile) (retImage *models.Image, err error) {
|
||||
scanned, err := scanner.Scanner.ScanNew(f)
|
||||
func (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f file.File) (*models.Gallery, error) {
|
||||
// don't create folder-based galleries for files in zip file
|
||||
if f.Base().ZipFileID != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
folderID := f.Base().ParentFolderID
|
||||
g, err := h.GalleryFinder.FindByFolderID(ctx, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("finding folder based gallery: %w", err)
|
||||
}
|
||||
|
||||
path := f.Path()
|
||||
checksum := scanned.Checksum
|
||||
|
||||
// grab a mutex on the checksum
|
||||
done := make(chan struct{})
|
||||
scanner.MutexManager.Claim(mutexType, checksum, done)
|
||||
defer close(done)
|
||||
|
||||
// check for image by checksum
|
||||
var existingImage *models.Image
|
||||
if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
||||
var err error
|
||||
existingImage, err = scanner.CreatorUpdater.FindByChecksum(ctx, checksum)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
if len(g) > 0 {
|
||||
gg := g[0]
|
||||
return gg, nil
|
||||
}
|
||||
|
||||
pathDisplayName := file.ZipPathDisplayName(path)
|
||||
|
||||
if existingImage != nil {
|
||||
exists := FileExists(existingImage.Path)
|
||||
if !scanner.CaseSensitiveFs {
|
||||
// #1426 - if file exists but is a case-insensitive match for the
|
||||
// original filename, then treat it as a move
|
||||
if exists && strings.EqualFold(path, existingImage.Path) {
|
||||
exists = false
|
||||
}
|
||||
}
|
||||
|
||||
if exists {
|
||||
logger.Infof("%s already exists. Duplicate of %s ", pathDisplayName, file.ZipPathDisplayName(existingImage.Path))
|
||||
return nil, nil
|
||||
} else {
|
||||
logger.Infof("%s already exists. Updating path...", pathDisplayName)
|
||||
imagePartial := models.ImagePartial{
|
||||
ID: existingImage.ID,
|
||||
Path: &path,
|
||||
}
|
||||
|
||||
if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
||||
retImage, err = scanner.CreatorUpdater.Update(ctx, imagePartial)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, existingImage.ID, plugin.ImageUpdatePost, nil, nil)
|
||||
}
|
||||
} else {
|
||||
logger.Infof("%s doesn't exist. Creating new item...", pathDisplayName)
|
||||
currentTime := time.Now()
|
||||
newImage := models.Image{
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
}
|
||||
newImage.SetFile(*scanned)
|
||||
newImage.Title.String = GetFilename(&newImage, scanner.StripFileExtension)
|
||||
newImage.Title.Valid = true
|
||||
|
||||
if err := SetFileDetails(&newImage); err != nil {
|
||||
logger.Error(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
||||
var err error
|
||||
retImage, err = scanner.CreatorUpdater.Create(ctx, newImage)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, retImage.ID, plugin.ImageCreatePost, nil, nil)
|
||||
// create a new folder-based gallery
|
||||
now := time.Now()
|
||||
newGallery := &models.Gallery{
|
||||
FolderID: &folderID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return
|
||||
logger.Infof("Creating folder-based gallery for %s", filepath.Dir(f.Base().Path))
|
||||
if err := h.GalleryFinder.Create(ctx, newGallery, nil); err != nil {
|
||||
return nil, fmt.Errorf("creating folder based gallery: %w", err)
|
||||
}
|
||||
|
||||
return newGallery, nil
|
||||
}
|
||||
|
||||
func (h *ScanHandler) associateFolderBasedGallery(ctx context.Context, newImage *models.Image, f file.File) error {
|
||||
g, err := h.getOrCreateFolderBasedGallery(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if g != nil && !intslice.IntInclude(newImage.GalleryIDs, g.ID) {
|
||||
newImage.GalleryIDs = append(newImage.GalleryIDs, g.ID)
|
||||
logger.Infof("Adding %s to folder-based gallery %s", f.Base().Path, g.Path())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// type Scanner struct {
|
||||
// file.Scanner
|
||||
|
||||
// StripFileExtension bool
|
||||
|
||||
// CaseSensitiveFs bool
|
||||
// TxnManager txn.Manager
|
||||
// CreatorUpdater FinderCreatorUpdater
|
||||
// Paths *paths.Paths
|
||||
// PluginCache *plugin.Cache
|
||||
// MutexManager *utils.MutexManager
|
||||
// }
|
||||
|
||||
// func FileScanner(hasher file.Hasher) file.Scanner {
|
||||
// return file.Scanner{
|
||||
// Hasher: hasher,
|
||||
// CalculateMD5: true,
|
||||
// }
|
||||
// }
|
||||
|
||||
// func (scanner *Scanner) ScanExisting(ctx context.Context, existing file.FileBased, file file.SourceFile) (retImage *models.Image, err error) {
|
||||
// scanned, err := scanner.Scanner.ScanExisting(existing, file)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// i := existing.(*models.Image)
|
||||
|
||||
// path := scanned.New.Path
|
||||
// oldChecksum := i.Checksum
|
||||
// changed := false
|
||||
|
||||
// if scanned.ContentsChanged() {
|
||||
// logger.Infof("%s has been updated: rescanning", path)
|
||||
|
||||
// // regenerate the file details as well
|
||||
// if err := SetFileDetails(i); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// changed = true
|
||||
// } else if scanned.FileUpdated() {
|
||||
// logger.Infof("Updated image file %s", path)
|
||||
|
||||
// changed = true
|
||||
// }
|
||||
|
||||
// if changed {
|
||||
// i.SetFile(*scanned.New)
|
||||
// i.UpdatedAt = time.Now()
|
||||
|
||||
// // we are operating on a checksum now, so grab a mutex on the checksum
|
||||
// done := make(chan struct{})
|
||||
// scanner.MutexManager.Claim(mutexType, scanned.New.Checksum, done)
|
||||
|
||||
// if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
||||
// // free the mutex once transaction is complete
|
||||
// defer close(done)
|
||||
// var err error
|
||||
|
||||
// // ensure no clashes of hashes
|
||||
// if scanned.New.Checksum != "" && scanned.Old.Checksum != scanned.New.Checksum {
|
||||
// dupe, _ := scanner.CreatorUpdater.FindByChecksum(ctx, i.Checksum)
|
||||
// if dupe != nil {
|
||||
// return fmt.Errorf("MD5 for file %s is the same as that of %s", path, dupe.Path)
|
||||
// }
|
||||
// }
|
||||
|
||||
// err = scanner.CreatorUpdater.Update(ctx, i)
|
||||
// return err
|
||||
// }); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// retImage = i
|
||||
|
||||
// // remove the old thumbnail if the checksum changed - we'll regenerate it
|
||||
// if oldChecksum != scanned.New.Checksum {
|
||||
// // remove cache dir of gallery
|
||||
// err = os.Remove(scanner.Paths.Generated.GetThumbnailPath(oldChecksum, models.DefaultGthumbWidth))
|
||||
// if err != nil {
|
||||
// logger.Errorf("Error deleting thumbnail image: %s", err)
|
||||
// }
|
||||
// }
|
||||
|
||||
// scanner.PluginCache.ExecutePostHooks(ctx, retImage.ID, plugin.ImageUpdatePost, nil, nil)
|
||||
// }
|
||||
|
||||
// return
|
||||
// }
|
||||
|
||||
// func (scanner *Scanner) ScanNew(ctx context.Context, f file.SourceFile) (retImage *models.Image, err error) {
|
||||
// scanned, err := scanner.Scanner.ScanNew(f)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// path := f.Path()
|
||||
// checksum := scanned.Checksum
|
||||
|
||||
// // grab a mutex on the checksum
|
||||
// done := make(chan struct{})
|
||||
// scanner.MutexManager.Claim(mutexType, checksum, done)
|
||||
// defer close(done)
|
||||
|
||||
// // check for image by checksum
|
||||
// var existingImage *models.Image
|
||||
// if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
||||
// var err error
|
||||
// existingImage, err = scanner.CreatorUpdater.FindByChecksum(ctx, checksum)
|
||||
// return err
|
||||
// }); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// pathDisplayName := file.ZipPathDisplayName(path)
|
||||
|
||||
// if existingImage != nil {
|
||||
// exists := FileExists(existingImage.Path)
|
||||
// if !scanner.CaseSensitiveFs {
|
||||
// // #1426 - if file exists but is a case-insensitive match for the
|
||||
// // original filename, then treat it as a move
|
||||
// if exists && strings.EqualFold(path, existingImage.Path) {
|
||||
// exists = false
|
||||
// }
|
||||
// }
|
||||
|
||||
// if exists {
|
||||
// logger.Infof("%s already exists. Duplicate of %s ", pathDisplayName, file.ZipPathDisplayName(existingImage.Path))
|
||||
// return nil, nil
|
||||
// } else {
|
||||
// logger.Infof("%s already exists. Updating path...", pathDisplayName)
|
||||
|
||||
// existingImage.Path = path
|
||||
// if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
||||
// return scanner.CreatorUpdater.Update(ctx, existingImage)
|
||||
// }); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// retImage = existingImage
|
||||
|
||||
// scanner.PluginCache.ExecutePostHooks(ctx, existingImage.ID, plugin.ImageUpdatePost, nil, nil)
|
||||
// }
|
||||
// } else {
|
||||
// logger.Infof("%s doesn't exist. Creating new item...", pathDisplayName)
|
||||
// currentTime := time.Now()
|
||||
// newImage := &models.Image{
|
||||
// CreatedAt: currentTime,
|
||||
// UpdatedAt: currentTime,
|
||||
// }
|
||||
// newImage.SetFile(*scanned)
|
||||
// fn := GetFilename(newImage, scanner.StripFileExtension)
|
||||
// newImage.Title = fn
|
||||
|
||||
// if err := SetFileDetails(newImage); err != nil {
|
||||
// logger.Error(err.Error())
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
||||
// return scanner.CreatorUpdater.Create(ctx, newImage)
|
||||
// }); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// retImage = newImage
|
||||
|
||||
// scanner.PluginCache.ExecutePostHooks(ctx, retImage.ID, plugin.ImageCreatePost, nil, nil)
|
||||
// }
|
||||
|
||||
// return
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user