精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
+220
View File
@@ -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
}
+55
View File
@@ -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
}
+46
View File
@@ -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
}
+214
View File
@@ -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)
}