精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/database/migrations"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
gorm_mysql "gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func GetMysqlAddress(addressString string) (string, error) {
|
||||
if addressString == "" {
|
||||
return "", fmt.Errorf("Environment variable %s missing, exiting", utils.EnvMysqlURL.GetName())
|
||||
}
|
||||
|
||||
config, err := mysql.ParseDSN(addressString)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not parse mysql url: %w", err)
|
||||
}
|
||||
|
||||
config.MultiStatements = true
|
||||
config.ParseTime = true
|
||||
|
||||
return config.FormatDSN(), nil
|
||||
}
|
||||
|
||||
func GetPostgresAddress(addressString string) (*url.URL, error) {
|
||||
if addressString == "" {
|
||||
return nil, fmt.Errorf("Environment variable %s missing, exiting", utils.EnvPostgresURL.GetName())
|
||||
}
|
||||
|
||||
address, err := url.Parse(addressString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse postgres url: %w", err)
|
||||
}
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func GetSqliteAddress(path string) (*url.URL, error) {
|
||||
if path == "" {
|
||||
path = "photoview.db"
|
||||
}
|
||||
|
||||
address, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse sqlite url (%s): %w", path, err)
|
||||
}
|
||||
|
||||
queryValues := address.Query()
|
||||
queryValues.Add("cache", "shared")
|
||||
queryValues.Add("mode", "rwc")
|
||||
// queryValues.Add("_busy_timeout", "60000") // 1 minute
|
||||
queryValues.Add("_journal_mode", "WAL") // Write-Ahead Logging (WAL) mode
|
||||
queryValues.Add("_locking_mode", "NORMAL") // allows concurrent reads and writes
|
||||
queryValues.Add("_foreign_keys", "ON") // Enforc foreign key constraints.
|
||||
address.RawQuery = queryValues.Encode()
|
||||
|
||||
// log.Panicf("%s", address.String())
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func ConfigureDatabase(config *gorm.Config) (*gorm.DB, error) {
|
||||
var databaseDialect gorm.Dialector
|
||||
driver := drivers.DatabaseDriverFromEnv()
|
||||
log.Printf("Utilizing %s database driver based on environment variables", driver)
|
||||
|
||||
switch driver {
|
||||
case drivers.MYSQL:
|
||||
mysqlAddress, err := GetMysqlAddress(utils.EnvMysqlURL.GetValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
databaseDialect = gorm_mysql.Open(mysqlAddress)
|
||||
case drivers.SQLITE:
|
||||
sqliteAddress, err := GetSqliteAddress(utils.EnvSqlitePath.GetValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
databaseDialect = sqlite.Open(sqliteAddress.String())
|
||||
|
||||
case drivers.POSTGRES:
|
||||
postgresAddress, err := GetPostgresAddress(utils.EnvPostgresURL.GetValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
databaseDialect = postgres.Open(postgresAddress.String())
|
||||
}
|
||||
|
||||
db, err := gorm.Open(databaseDialect, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// SetupDatabase connects to the database using environment variables
|
||||
func SetupDatabase() (*gorm.DB, error) {
|
||||
|
||||
config := gorm.Config{}
|
||||
|
||||
// Configure database logging
|
||||
if utils.DevelopmentMode() {
|
||||
config.Logger = logger.Default.LogMode(logger.Info)
|
||||
} else {
|
||||
config.Logger = logger.Default.LogMode(logger.Warn)
|
||||
}
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
for retryCount := 1; retryCount <= 5; retryCount++ {
|
||||
|
||||
var err error
|
||||
db, err = ConfigureDatabase(&config)
|
||||
if err == nil {
|
||||
sqlDB, dbErr := db.DB()
|
||||
if dbErr != nil {
|
||||
return nil, dbErr
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err = sqlDB.PingContext(ctx)
|
||||
cancel()
|
||||
|
||||
sqlDB.SetMaxOpenConns(80)
|
||||
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("WARN: Could not ping database: %s. Will retry after 5 seconds\n", err)
|
||||
time.Sleep(time.Duration(5) * time.Second)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
var database_models []interface{} = []interface{}{
|
||||
&models.User{},
|
||||
&models.AccessToken{},
|
||||
&models.SiteInfo{},
|
||||
&models.Media{},
|
||||
&models.MediaURL{},
|
||||
&models.Album{},
|
||||
&models.MediaEXIF{},
|
||||
&models.VideoMetadata{},
|
||||
&models.ShareToken{},
|
||||
&models.UserMediaData{},
|
||||
&models.UserAlbums{},
|
||||
&models.UserPreferences{},
|
||||
|
||||
// Face detection
|
||||
&models.FaceGroup{},
|
||||
&models.ImageFace{},
|
||||
}
|
||||
|
||||
func MigrateDatabase(db *gorm.DB) error {
|
||||
|
||||
if err := db.SetupJoinTable(&models.User{}, "Albums", &models.UserAlbums{}); err != nil {
|
||||
log.Printf("Setup UserAlbums join table failed: %v\n", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(database_models...); err != nil {
|
||||
log.Printf("Auto migration failed: %v\n", err)
|
||||
}
|
||||
|
||||
// v2.1.0 - Replaced by Media.CreatedAt
|
||||
if db.Migrator().HasColumn(&models.Media{}, "date_imported") {
|
||||
db.Migrator().DropColumn(&models.Media{}, "date_imported")
|
||||
}
|
||||
|
||||
// v2.3.0 - Changed type of MediaEXIF.Exposure and MediaEXIF.Flash
|
||||
// from string values to decimal and int respectively
|
||||
if err := migrateExifFields(db); err != nil {
|
||||
log.Printf("Failed to run exif fields migration: %v\n", err)
|
||||
}
|
||||
|
||||
// Remove invalid GPS data from DB
|
||||
if err := migrations.MigrateForExifGPSCorrection(db); err != nil {
|
||||
log.Printf("Failed to run exif GPS correction migration: %v\n", err)
|
||||
}
|
||||
|
||||
// v2.5.0 - Remove Thumbnail Method for Downsampliing filters
|
||||
if db.Migrator().HasColumn(&models.SiteInfo{}, "thumbnail_method") {
|
||||
db.Migrator().DropColumn(&models.SiteInfo{}, "thumbnail_method")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearDatabase(db *gorm.DB) error {
|
||||
var errs []error
|
||||
for _, model := range database_models {
|
||||
if err := db.Migrator().DropTable(model); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
return fmt.Errorf("drop tables error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DatabaseDriverType represents the name of a database driver
|
||||
type DatabaseDriverType string
|
||||
|
||||
const (
|
||||
MYSQL DatabaseDriverType = "mysql"
|
||||
SQLITE DatabaseDriverType = "sqlite"
|
||||
POSTGRES DatabaseDriverType = "postgres"
|
||||
)
|
||||
|
||||
func DatabaseDriverFromEnv() DatabaseDriverType {
|
||||
|
||||
var driver DatabaseDriverType
|
||||
driverString := strings.ToLower(utils.EnvDatabaseDriver.GetValue())
|
||||
|
||||
switch driverString {
|
||||
case "mysql":
|
||||
driver = MYSQL
|
||||
case "sqlite":
|
||||
driver = SQLITE
|
||||
case "postgres":
|
||||
driver = POSTGRES
|
||||
default:
|
||||
driver = MYSQL
|
||||
}
|
||||
|
||||
return driver
|
||||
}
|
||||
|
||||
func (driver DatabaseDriverType) MatchDatabase(db *gorm.DB) bool {
|
||||
return db.Dialector.Name() == string(driver)
|
||||
}
|
||||
|
||||
func GetDatabaseDriverType(db *gorm.DB) (driver DatabaseDriverType) {
|
||||
switch db.Dialector.Name() {
|
||||
case "mysql":
|
||||
driver = MYSQL
|
||||
case "sqlite":
|
||||
driver = SQLITE
|
||||
case "postgres":
|
||||
driver = POSTGRES
|
||||
default:
|
||||
driver = MYSQL
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DateComponent a component of a date (day, month, year)
|
||||
type DateComponent string
|
||||
|
||||
const (
|
||||
DateCompYear DateComponent = "YEAR"
|
||||
DateCompMonth DateComponent = "MONTH"
|
||||
DateCompDay DateComponent = "DAY"
|
||||
)
|
||||
|
||||
// DateExtract is a helper function that is used to generate the proper SQL syntax
|
||||
// for extracting date components (day, month, year) for different database backends.
|
||||
func DateExtract(db *gorm.DB, component DateComponent, attribute string) string {
|
||||
|
||||
var result string
|
||||
|
||||
switch drivers.GetDatabaseDriverType(db) {
|
||||
case drivers.MYSQL, drivers.POSTGRES:
|
||||
result = fmt.Sprintf("EXTRACT(%s FROM %s)", component, attribute)
|
||||
case drivers.SQLITE:
|
||||
var sqliteFormatted string
|
||||
switch component {
|
||||
case DateCompYear:
|
||||
sqliteFormatted = "%Y"
|
||||
case DateCompMonth:
|
||||
sqliteFormatted = "%m"
|
||||
case DateCompDay:
|
||||
sqliteFormatted = "%d"
|
||||
}
|
||||
|
||||
result = fmt.Sprintf("CAST(strftime('%s', %s) AS INTEGER)", sqliteFormatted, attribute)
|
||||
default:
|
||||
log.Panicf("unsupported database backend: %s", drivers.GetDatabaseDriverType(db))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type exifModel struct {
|
||||
ID int `gorm:"primarykey"`
|
||||
Exposure *string
|
||||
Flash *string
|
||||
}
|
||||
|
||||
var flashDescriptions = map[int]string{
|
||||
0x0: "No Flash",
|
||||
0x1: "Fired",
|
||||
0x5: "Fired, Return not detected",
|
||||
0x7: "Fired, Return detected",
|
||||
0x8: "On, Did not fire",
|
||||
0x9: "On, Fired",
|
||||
0xD: "On, Return not detected",
|
||||
0xF: "On, Return detected",
|
||||
0x10: "Off, Did not fire",
|
||||
0x14: "Off, Did not fire, Return not detected",
|
||||
0x18: "Auto, Did not fire",
|
||||
0x19: "Auto, Fired",
|
||||
0x1D: "Auto, Fired, Return not detected",
|
||||
0x1F: "Auto, Fired, Return detected",
|
||||
0x20: "No flash function",
|
||||
0x30: "Off, No flash function",
|
||||
0x41: "Fired, Red-eye reduction",
|
||||
0x45: "Fired, Red-eye reduction, Return not detected",
|
||||
0x47: "Fired, Red-eye reduction, Return detected",
|
||||
0x49: "On, Red-eye reduction",
|
||||
0x4D: "On, Red-eye reduction, Return not detected",
|
||||
0x4F: "On, Red-eye reduction, Return detected",
|
||||
0x50: "Off, Red-eye reduction",
|
||||
0x58: "Auto, Did not fire, Red-eye reduction",
|
||||
0x59: "Auto, Fired, Red-eye reduction",
|
||||
0x5D: "Auto, Fired, Red-eye reduction, Return not detected",
|
||||
0x5F: "Auto, Fired, Red-eye reduction, Return detected",
|
||||
}
|
||||
|
||||
// Migrate MediaExif fields "exposure" and "flash" from strings to integers
|
||||
func migrateExifFields(db *gorm.DB) error {
|
||||
mediaExifColumns, err := db.Migrator().ColumnTypes(&models.MediaEXIF{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, exifCol := range mediaExifColumns {
|
||||
if err := parseExposure(exifCol, db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := parseFlash(exifCol, db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&models.MediaEXIF{}); err != nil {
|
||||
return fmt.Errorf("failed to auto migrate media_exif after exposure conversion: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func parseFlash(exifCol gorm.ColumnType, db *gorm.DB) error {
|
||||
if exifCol.Name() == "flash" {
|
||||
switch exifCol.DatabaseTypeName() {
|
||||
case "double", "numeric", "real", "bigint", "integer":
|
||||
// correct type, do nothing
|
||||
default:
|
||||
// do migration
|
||||
if err := migrateExifFieldsFlash(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseExposure(exifCol gorm.ColumnType, db *gorm.DB) error {
|
||||
if exifCol.Name() == "exposure" {
|
||||
switch exifCol.DatabaseTypeName() {
|
||||
case "double", "numeric", "real", "bigint", "integer":
|
||||
// correct type, do nothing
|
||||
default:
|
||||
// do migration
|
||||
if err := migrateExifFieldsExposure(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateExifFieldsExposure(db *gorm.DB) error {
|
||||
log.Println("Migrating `media_exif.exposure` from string to double")
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
if err := tx.Exec("UPDATE media_exif SET exposure = NULL WHERE exposure = ''").Error; err != nil {
|
||||
return fmt.Errorf("convert flash attribute empty values to NULL: %w", err)
|
||||
}
|
||||
|
||||
var results []exifModel
|
||||
|
||||
return calculateExposure(tx, results)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrating `media_exif.exposure` failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func calculateExposure(tx *gorm.DB, results []exifModel) error {
|
||||
return tx.Model(&exifModel{}).Table("media_exif").Where("exposure LIKE '%/%'").FindInBatches(
|
||||
&results, 100, func(tx *gorm.DB, batch int) error {
|
||||
for _, result := range results {
|
||||
|
||||
if result.Exposure == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
frac := strings.Split(*result.Exposure, "/")
|
||||
if len(frac) != 2 {
|
||||
return fmt.Errorf("failed to convert exposure value (%s) expected format x/y", frac)
|
||||
}
|
||||
|
||||
numerator, err := strconv.ParseFloat(frac[0], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
denominator, err := strconv.ParseFloat(frac[1], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decimalValue := numerator / denominator
|
||||
*result.Exposure = fmt.Sprintf("%f", decimalValue)
|
||||
}
|
||||
|
||||
tx.Save(&results)
|
||||
|
||||
return nil
|
||||
}).Error
|
||||
}
|
||||
|
||||
func migrateExifFieldsFlash(db *gorm.DB) error {
|
||||
log.Println("Migrating `media_exif.flash` from string to int")
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
var dataType string
|
||||
if err := tx.Raw(
|
||||
"SELECT data_type FROM information_schema.columns WHERE table_name = 'media_exif' AND column_name = 'flash';").
|
||||
Find(&dataType).Error; err != nil {
|
||||
|
||||
return fmt.Errorf("read data_type of column media_exif.flash: %w", err)
|
||||
}
|
||||
|
||||
if dataType == "bigint" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Exec("UPDATE media_exif SET flash = NULL WHERE flash = ''").Error; err != nil {
|
||||
return fmt.Errorf("convert flash attribute empty values to NULL: %w", err)
|
||||
}
|
||||
|
||||
var results []exifModel
|
||||
|
||||
return replaceFlashValues(tx, results)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrating `media_exif.flash` failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceFlashValues(tx *gorm.DB, results []exifModel) error {
|
||||
return tx.Model(&exifModel{}).Table("media_exif").Where("flash IS NOT NULL").FindInBatches(
|
||||
&results, 100, func(tx *gorm.DB, batch int) error {
|
||||
for _, result := range results {
|
||||
|
||||
if result.Flash == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for index, name := range flashDescriptions {
|
||||
if *result.Flash == name {
|
||||
*result.Flash = fmt.Sprintf("%d", index)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Save(&results)
|
||||
|
||||
return nil
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MigrateForExifGPSCorrection finds and removes invalid GPS data from media_exif table
|
||||
func MigrateForExifGPSCorrection(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.MediaEXIF{}).
|
||||
Where("ABS(gps_longitude) > ?", 90).
|
||||
Or("ABS(gps_latitude) > ?", 90).
|
||||
Updates(map[string]interface{}{
|
||||
"gps_latitude": nil,
|
||||
"gps_longitude": nil,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("failed to remove invalid GPS data from media_exif table: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoview/photoview/api/database/migrations"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestExifMigration(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
defer db.Exec("DELETE FROM media_exif") // Clean up after test
|
||||
|
||||
// Create test data
|
||||
exifEntries := []models.MediaEXIF{
|
||||
{GPSLatitude: floatPtr(90.1), GPSLongitude: floatPtr(90.0)}, // Invalid GPSLatitude
|
||||
{GPSLatitude: floatPtr(-90.1), GPSLongitude: floatPtr(-90.0)}, // Invalid GPSLatitude
|
||||
{GPSLatitude: floatPtr(90.0), GPSLongitude: floatPtr(90.1)}, // Invalid GPSLongitude
|
||||
{GPSLatitude: floatPtr(-90.0), GPSLongitude: floatPtr(-90.1)}, // Invalid GPSLongitude
|
||||
{GPSLatitude: floatPtr(90.0), GPSLongitude: floatPtr(90.0)}, // Valid GPS data
|
||||
{GPSLatitude: floatPtr(-90.0), GPSLongitude: floatPtr(-90.0)}, // Valid GPS data
|
||||
{GPSLatitude: floatPtr(90.1), GPSLongitude: floatPtr(90.1)}, // Invalid GPSLatitude and GPSLongitude
|
||||
{GPSLatitude: floatPtr(-90.1), GPSLongitude: floatPtr(-90.1)}, // Invalid GPSLatitude and GPSLongitude
|
||||
}
|
||||
|
||||
// Insert test data
|
||||
for _, entry := range exifEntries {
|
||||
assert.NoError(t, db.Create(&entry).Error)
|
||||
}
|
||||
|
||||
// Run migration
|
||||
assert.NoError(t, migrations.MigrateForExifGPSCorrection(db))
|
||||
|
||||
// Validate the results
|
||||
var results []models.MediaEXIF
|
||||
assert.NoError(t, db.Find(&results).Error)
|
||||
|
||||
for _, entry := range results {
|
||||
if entry.GPSLatitude != nil {
|
||||
assert.LessOrEqual(t, math.Abs(*entry.GPSLatitude), 90.0, "GPSLatitude should be within [-90, 90]: %+v", entry)
|
||||
}
|
||||
if entry.GPSLongitude != nil {
|
||||
assert.LessOrEqual(t, math.Abs(*entry.GPSLongitude), 90.0, "GPSLongitude should be within [-90, 90]: %+v", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
Reference in New Issue
Block a user