Config: Support YAML filenames with alternative extensions #5304

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-11-02 11:33:40 +01:00
parent 50e77e3a9d
commit d6f0e808eb
15 changed files with 329 additions and 159 deletions

View File

@@ -1,6 +1,6 @@
# PhotoPrism® Repository Guidelines
**Last Updated:** October 28, 2025
**Last Updated:** November 2, 2025
## Purpose
@@ -331,6 +331,7 @@ Note: Across our public documentation, official images, and in production, the c
### API & Config Changes
- Respect precedence: `options.yml` overrides CLI/env values, which override defaults. When adding a new option, update `internal/config/options.go` (yaml/flag tags), register it in `internal/config/flags.go`, expose a getter, surface it in `*config.Report()`, and write generated values back to `options.yml` by setting `c.options.OptionsYaml` before persisting. Use `CliTestContext` in `internal/config/test.go` to exercise new flags.
- Use `pkg/fs.ConfigFilePath` when you need a config filename so existing `.yml` files remain valid and new installs can adopt `.yaml` transparently (the helper also covers other paired extensions such as `.toml`/`.tml`).
- When touching configuration in Go code, use the public accessors on `*config.Config` (e.g. `Config.JWKSUrl()`, `Config.SetJWKSUrl()`, `Config.ClusterUUID()`) instead of mutating `Config.Options()` directly; reserve raw option tweaks for test fixtures only.
- When introducing new metadata sources (e.g., `SrcOllama`, `SrcOpenAI`), define them in both `internal/entity/src.go` and the frontend lookup tables (`frontend/src/common/util.js`) so UI badges and server priorities stay aligned.
- Vision worker scheduling is controlled via `VisionSchedule` / `VisionFilter` and the `Run` property set in `vision.yml`. Utilities like `vision.FilterModels` and `entity.Photo.ShouldGenerateLabels/Caption` help decide when work is required before loading media files.

View File

@@ -1,6 +1,6 @@
PhotoPrism — Backend CODEMAP
**Last Updated:** October 29, 2025
**Last Updated:** November 2, 2025
Purpose
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
@@ -142,6 +142,7 @@ Common HowTos
- Expose a getter (e.g., in `config_server.go` or topic file)
- Append to `rows` in `*config.Report()` after the same option as in `options.go`
- If value must persist, write back to `options.yml` and reload into memory
- When you need the path to defaults/options/settings files, call `pkg/fs.ConfigFilePath` so `.yml` and `.yaml` stay interchangeable.
- Tests: cover CLI/env/file precedence (see `internal/config/test.go` helpers)
- Touch the DB schema

View File

@@ -253,12 +253,14 @@ func (c *Config) ConfigPath() string {
return fs.Abs(c.options.ConfigPath)
}
// OptionsYaml returns the config options YAML filename.
// OptionsYaml returns the absolute path to the options configuration file.
// It relies on fs.ConfigFilePath so legacy `.yml` files keep working while
// newly created instances may use `.yaml` without additional wiring.
func (c *Config) OptionsYaml() string {
configPath := c.ConfigPath()
if c.options.OptionsYaml == "" {
return filepath.Join(configPath, "options.yml")
return fs.ConfigFilePath(configPath, "options", fs.ExtYml)
}
return fs.Abs(c.options.OptionsYaml)
@@ -269,17 +271,23 @@ func (c *Config) DefaultsYaml() string {
return fs.Abs(c.options.DefaultsYaml)
}
// HubConfigFile returns the backend api config file name.
// HubConfigFile returns the backend API config filename, honoring either the
// traditional `.yml` suffix or an existing `.yaml` variant in the config
// directory.
func (c *Config) HubConfigFile() string {
return filepath.Join(c.ConfigPath(), "hub.yml")
return fs.ConfigFilePath(c.ConfigPath(), "hub", fs.ExtYml)
}
// SettingsYaml returns the settings YAML filename.
// SettingsYaml returns the path to the UI settings file. Like other helpers it
// defers to fs.ConfigFilePath so administrators can store the file as
// `settings.yml` or `settings.yaml`.
func (c *Config) SettingsYaml() string {
return filepath.Join(c.ConfigPath(), "settings.yml")
return fs.ConfigFilePath(c.ConfigPath(), "settings", fs.ExtYml)
}
// SettingsYamlDefaults returns the default settings YAML filename.
// SettingsYamlDefaults returns the defaults file that should seed new settings
// files. When both `.yml` and `.yaml` exist, the helper mirrors
// SettingsYaml()'s selection logic to keep behavior consistent.
func (c *Config) SettingsYamlDefaults(settingsYml string) string {
if settingsYml != "" && fs.FileExists(settingsYml) {
// Use regular settings YAML file.
@@ -287,7 +295,7 @@ func (c *Config) SettingsYamlDefaults(settingsYml string) string {
// Use regular settings YAML file.
} else if dir := filepath.Dir(defaultsYml); dir == "" || dir == "." {
// Use regular settings YAML file.
} else if fileName := filepath.Join(dir, "settings.yml"); settingsYml == "" || fs.FileExistsNotEmpty(fileName) {
} else if fileName := fs.ConfigFilePath(dir, "settings", fs.ExtYml); settingsYml == "" || fs.FileExistsNotEmpty(fileName) {
// Use default settings YAML file.
return fileName
}

View File

@@ -10,11 +10,9 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
// VisionYaml returns the vision config YAML filename.
//
// TODO: Call fs.YamlFilePath to use ".yaml" extension for new YAML files, unless a .yml" file already exists.
//
// return fs.YamlFilePath("vision", c.ConfigPath(), c.options.VisionYaml)
// VisionYaml returns the path to the computer-vision configuration file,
// preferring an explicit override and otherwise letting fs.ConfigFilePath pick
// the right `.yml`/`.yaml` variant in the config directory.
func (c *Config) VisionYaml() string {
if c == nil {
return ""
@@ -23,7 +21,7 @@ func (c *Config) VisionYaml() string {
if c.options.VisionYaml != "" {
return fs.Abs(c.options.VisionYaml)
} else {
return filepath.Join(c.ConfigPath(), "vision.yml")
return fs.ConfigFilePath(c.ConfigPath(), "vision", fs.ExtYml)
}
}

View File

@@ -198,16 +198,20 @@ var Flags = CliFlags{
}}, {
Flag: &cli.PathFlag{
Name: "config-path",
Aliases: []string{"c"},
Aliases: []string{"config", "c"},
Usage: "config storage `PATH` or options.yml filename, values in this file override CLI flags and environment variables if present",
EnvVars: EnvVars("CONFIG_PATH"),
TakesFile: true,
}}, {
Flag: &cli.StringFlag{
Name: "defaults-yaml",
Aliases: []string{"y"},
Usage: "loads default config values from `FILENAME` if it exists, does not override CLI flags or environment variables",
Value: "/etc/photoprism/defaults.yml",
Name: "defaults-yaml",
// Alias was changed from "y" to "defaults" since "y" is a reserved alias for "yes".
// Since our examples and end-user docs for this flag don't include any aliases, the change should be safe.
Aliases: []string{"defaults"},
Usage: "loads default config values from `FILENAME` if it exists, does not override CLI flags or environment variables",
// fs.ConfigFilePath lets existing installations keep a defaults.yml file
// while new deployments may drop in defaults.yaml without updating the flag.
Value: fs.ConfigFilePath("/etc/photoprism", "defaults", fs.ExtYml),
EnvVars: EnvVars("DEFAULTS_YAML"),
TakesFile: true,
}}, {

View File

@@ -331,7 +331,9 @@ func NewTestConfig(dbName string) *Config {
log.Fatalf("config: %s", err.Error())
}
if err := s.Save(filepath.Join(c.ConfigPath(), "settings.yml")); err != nil {
// Save settings next to the test config path, reusing any existing
// `.yaml`/`.yml` variant so the tests mirror production behavior.
if err := s.Save(fs.ConfigFilePath(c.ConfigPath(), "settings", fs.ExtYml)); err != nil {
log.Fatalf("config: %s", err.Error())
}

93
pkg/fs/config.go Normal file
View File

@@ -0,0 +1,93 @@
package fs
import (
"os"
"path/filepath"
)
// ConfigFilePath builds an absolute path for a configuration file using the
// provided directory, base name, and preferred extension. If a file with the
// preferred extension already exists, that path is returned. Otherwise the
// helper searches for known sibling extensions (for example `.yaml` vs
// `.yml`) so callers transparently reuse whichever variant an admin created.
// When no matching file exists, the preferred extension is appended.
func ConfigFilePath(configPath, baseName, defaultExt string) string {
// Return empty file path is no file name was specified.
if baseName == "" {
return ""
}
// Search file in current directory if configPath is emtpy.
if configPath == "" {
if dir, err := os.Getwd(); err == nil && dir != "" {
configPath = dir
}
}
defaultPath := filepath.Join(configPath, baseName+defaultExt)
// If the default file exists, return its file path and look no further.
if FileExists(defaultPath) {
return defaultPath
}
// If the default file does not exist, check for a file
// with an alternative extension that already exists.
switch defaultExt {
case ExtNone:
if altPath := filepath.Join(configPath, baseName+ExtLocal); FileExists(altPath) {
return altPath
}
case ExtYml:
if altPath := filepath.Join(configPath, baseName+ExtYaml); FileExists(altPath) {
return altPath
}
case ExtYaml:
if altPath := filepath.Join(configPath, baseName+ExtYml); FileExists(altPath) {
return altPath
}
case ExtGeoJson:
if altPath := filepath.Join(configPath, baseName+ExtJson); FileExists(altPath) {
return altPath
}
case ExtTml:
if altPath := filepath.Join(configPath, baseName+ExtToml); FileExists(altPath) {
return altPath
}
case ExtToml:
if altPath := filepath.Join(configPath, baseName+ExtTml); FileExists(altPath) {
return altPath
}
case ExtMd:
if altPath := filepath.Join(configPath, baseName+ExtMarkdown); FileExists(altPath) {
return altPath
}
case ExtMarkdown:
if altPath := filepath.Join(configPath, baseName+ExtMd); FileExists(altPath) {
return altPath
}
case ExtHTML:
if altPath := filepath.Join(configPath, baseName+ExtHTM); FileExists(altPath) {
return altPath
} else if altPath = filepath.Join(configPath, baseName+ExtXHTML); FileExists(altPath) {
return altPath
}
case ExtHTM:
if altPath := filepath.Join(configPath, baseName+ExtHTML); FileExists(altPath) {
return altPath
} else if altPath = filepath.Join(configPath, baseName+ExtXHTML); FileExists(altPath) {
return altPath
}
case ExtPb:
if altPath := filepath.Join(configPath, baseName+ExtProto); FileExists(altPath) {
return altPath
}
case ExtProto:
if altPath := filepath.Join(configPath, baseName+ExtPb); FileExists(altPath) {
return altPath
}
}
// Return default config file path.
return defaultPath
}

117
pkg/fs/config_test.go Normal file
View File

@@ -0,0 +1,117 @@
package fs
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfigFilePath(t *testing.T) {
pwd, _ := os.Getwd()
t.Run("EmptyName", func(t *testing.T) {
assert.Equal(t, "", ConfigFilePath("", "", ""))
assert.Equal(t, "", ConfigFilePath("", "", ExtYml))
assert.Equal(t, "", ConfigFilePath("./testdata", "", ExtYml))
})
t.Run("EmptyPath", func(t *testing.T) {
assert.Equal(t, filepath.Join(pwd, "example.json"), ConfigFilePath("", "example", ExtJson))
assert.Equal(t, filepath.Join(pwd, "example.yml"), ConfigFilePath("", "example", ExtYml))
assert.Equal(t, filepath.Join(pwd, "example.yaml"), ConfigFilePath("", "example", ExtYaml))
})
t.Run("ExtNone", func(t *testing.T) {
configPath := "testdata/config"
envPath := filepath.Join(configPath, ".env")
fooPath := filepath.Join(configPath, ".foo")
fooPathLocal := fooPath + ExtLocal
assert.Equal(t, envPath, ConfigFilePath(configPath, ".env", ExtNone))
assert.Equal(t, fooPathLocal, ConfigFilePath(configPath, ".foo", ExtNone))
})
t.Run("YmlFileExists", func(t *testing.T) {
dir := t.TempDir()
name := "app-config"
// Create .yml file
ymlPath := filepath.Join(dir, name+ExtYml)
err := os.WriteFile(ymlPath, []byte("foo: bar\n"), ModeFile)
if err != nil {
t.Fatalf("write %s: %v", ymlPath, err)
}
assert.Equal(t, ymlPath, ConfigFilePath(dir, name, ExtYml))
assert.Equal(t, ymlPath, ConfigFilePath(dir, name, ExtYaml))
})
t.Run("YamlFilesMissing", func(t *testing.T) {
dir := t.TempDir()
name := "settings"
// Ensure .yml does not exist; do not create it.
ymlPath := filepath.Join(dir, name+ExtYml)
yamlPath := filepath.Join(dir, name+ExtYaml)
assert.Equal(t, ymlPath, ConfigFilePath(dir, name, ExtYml))
assert.Equal(t, yamlPath, ConfigFilePath(dir, name, ExtYaml))
})
t.Run("BothYamlFilesExist", func(t *testing.T) {
dir := t.TempDir()
name := "prefs"
// Create both files.
ymlPath := filepath.Join(dir, name+ExtYml)
yamlPath := filepath.Join(dir, name+ExtYaml)
if err := os.WriteFile(ymlPath, []byte("a: 1\n"), ModeFile); err != nil {
t.Fatalf("write %s: %v", ymlPath, err)
}
if err := os.WriteFile(yamlPath, []byte("a: 2\n"), ModeFile); err != nil {
t.Fatalf("write %s: %v", yamlPath, err)
}
assert.Equal(t, ymlPath, ConfigFilePath(dir, name, ExtYml))
assert.Equal(t, yamlPath, ConfigFilePath(dir, name, ExtYaml))
})
t.Run("AlternateExtensions", func(t *testing.T) {
tests := []struct {
name string
defaultExt string
altExts []string
expectPathIdx int
}{
{name: "geo", defaultExt: ExtGeoJson, altExts: []string{ExtJson}},
{name: "tml", defaultExt: ExtTml, altExts: []string{ExtToml}},
{name: "toml", defaultExt: ExtToml, altExts: []string{ExtTml}},
{name: "md", defaultExt: ExtMd, altExts: []string{ExtMarkdown}},
{name: "markdown", defaultExt: ExtMarkdown, altExts: []string{ExtMd}},
{name: "html", defaultExt: ExtHTML, altExts: []string{ExtHTM, ExtXHTML}},
{name: "html-xhtml", defaultExt: ExtHTML, altExts: []string{ExtXHTML}, expectPathIdx: 0},
{name: "htm", defaultExt: ExtHTM, altExts: []string{ExtHTML, ExtXHTML}},
{name: "htm-xhtml", defaultExt: ExtHTM, altExts: []string{ExtXHTML}, expectPathIdx: 0},
{name: "pb", defaultExt: ExtPb, altExts: []string{ExtProto}},
{name: "proto", defaultExt: ExtProto, altExts: []string{ExtPb}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
base := "config-" + tc.name
var paths []string
for _, ext := range tc.altExts {
path := filepath.Join(dir, base+ext)
if err := os.WriteFile(path, []byte(ext+" file"), ModeFile); err != nil {
t.Fatalf("write %s: %v", path, err)
}
paths = append(paths, path)
}
expected := paths[tc.expectPathIdx]
got := ConfigFilePath(dir, base, tc.defaultExt)
assert.Equal(t, expected, got)
})
}
})
}

View File

@@ -15,7 +15,7 @@ func TestDirs(t *testing.T) {
t.Fatal(err)
}
assert.Len(t, result, 8)
assert.Len(t, result, 9)
assert.Contains(t, result, "/directory")
assert.Contains(t, result, "/directory/subdirectory")
assert.Contains(t, result, "/directory/subdirectory/animals")

View File

@@ -6,52 +6,63 @@ import (
)
const (
ExtPDF = ".pdf"
ExtJpeg = ".jpg"
ExtPng = ".png"
ExtDng = ".dng"
ExtThm = ".thm"
ExtH264 = ".h264"
ExtAvc = ".avc"
ExtAvc1 = ".avc1"
ExtDva = ".dva"
ExtDva1 = ".dva1"
ExtAvc2 = ".avc2"
ExtAvc3 = ".avc3"
ExtDvav = ".dvav"
ExtAvc10 = ".avc10"
ExtH265 = ".h265"
ExtHvc = ".hvc"
ExtHvc1 = ".hvc1"
ExtDvh = ".dvh"
ExtDvh1 = ".dvh1"
ExtHvc2 = ".hvc2"
ExtHvc3 = ".hvc3"
ExtHvc10 = ".hvc10"
ExtHevc = ".hevc"
ExtHevc10 = ".hevc10"
ExtHev = ".hev"
ExtDvhe = ".dvhe"
ExtHev1 = ".hev1"
ExtHev2 = ".hev2"
ExtHev3 = ".hev3"
ExtHev10 = ".hev10"
ExtH266 = ".h266"
ExtVvc = ".vvc"
ExtVvc1 = ".vvc1"
ExtEvc = ".evc"
ExtEvc1 = ".evc1"
ExtMp4 = ".mp4"
ExtMov = ".mov"
ExtQT = ".qt"
ExtYml = ".yml"
ExtYaml = ".yaml"
ExtJson = ".json"
ExtXml = ".xml"
ExtXMP = ".xmp"
ExtTxt = ".txt"
ExtMd = ".md"
ExtZip = ".zip"
ExtNone = ""
ExtLocal = ".local"
ExtPDF = ".pdf"
ExtJpeg = ".jpg"
ExtPng = ".png"
ExtDng = ".dng"
ExtThm = ".thm"
ExtH264 = ".h264"
ExtAvc = ".avc"
ExtAvc1 = ".avc1"
ExtDva = ".dva"
ExtDva1 = ".dva1"
ExtAvc2 = ".avc2"
ExtAvc3 = ".avc3"
ExtDvav = ".dvav"
ExtAvc10 = ".avc10"
ExtH265 = ".h265"
ExtHvc = ".hvc"
ExtHvc1 = ".hvc1"
ExtDvh = ".dvh"
ExtDvh1 = ".dvh1"
ExtHvc2 = ".hvc2"
ExtHvc3 = ".hvc3"
ExtHvc10 = ".hvc10"
ExtHevc = ".hevc"
ExtHevc10 = ".hevc10"
ExtHev = ".hev"
ExtDvhe = ".dvhe"
ExtHev1 = ".hev1"
ExtHev2 = ".hev2"
ExtHev3 = ".hev3"
ExtHev10 = ".hev10"
ExtH266 = ".h266"
ExtVvc = ".vvc"
ExtVvc1 = ".vvc1"
ExtEvc = ".evc"
ExtEvc1 = ".evc1"
ExtMp4 = ".mp4"
ExtMov = ".mov"
ExtQT = ".qt"
ExtYml = ".yml"
ExtYaml = ".yaml"
ExtTml = ".tml"
ExtToml = ".toml"
ExtJson = ".json"
ExtGeoJson = ".geojson"
ExtXml = ".xml"
ExtXMP = ".xmp"
ExtHTM = ".htm"
ExtHTML = ".html"
ExtXHTML = ".xhtml"
ExtTxt = ".txt"
ExtMd = ".md"
ExtMarkdown = ".markdown"
ExtPb = ".pb"
ExtProto = ".proto"
ExtZip = ".zip"
)
// Ext returns all extension of a file name including the dots.

1
pkg/fs/testdata/config/.foo.local vendored Normal file
View File

@@ -0,0 +1 @@
TEST=bar

20
pkg/fs/testdata/config/settings.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
UI:
Scrollbar: true
Zoom: false
Theme: custom
Language: en
TimeZone: Europe/Berlin
StartPage: default
Albums:
Download:
Name: share
Disabled: false
Originals: true
MediaRaw: false
MediaSidecar: false
Order:
Album: oldest
Folder: added
Moment: oldest
State: newest
Month: oldest

View File

@@ -108,8 +108,10 @@ func TestSkipWalk(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, dirs, "testdata/directory/subdirectory/.hiddendir")
expected := []string{
mustSkip := []string{
"testdata",
"testdata/config",
"testdata/config/.foo.local",
"testdata/directory",
"testdata/directory/.ppignore",
"testdata/directory/bar.txt",
@@ -131,6 +133,6 @@ func TestSkipWalk(t *testing.T) {
"testdata/originals",
}
assert.Equal(t, expected, skipped)
assert.Equal(t, mustSkip, skipped)
})
}

View File

@@ -1,25 +0,0 @@
package fs
import (
"path/filepath"
)
// YamlFilePath returns the appropriate YAML file name to use. This can be either
// the absolute path of the custom file name passed as the first argument, the default
// name with a ".yml" extension if it already exists, or the default name with a ".yaml"
// extension if a ".yml" file does not exist. This facilitates the transition from ".yml"
// to the new default YAML file extension, ".yaml".
func YamlFilePath(yamlName, yamlDir, customFileName string) string {
// Return custom file name with absolute path.
if customFileName != "" {
return Abs(customFileName)
}
// If the file already exists, return the file path with the legacy "*.yml" extension.
if filePathYml := filepath.Join(yamlDir, yamlName+ExtYml); FileExists(filePathYml) {
return filePathYml
}
// Return file path with ".yaml" extension.
return filepath.Join(yamlDir, yamlName+ExtYaml)
}

View File

@@ -1,63 +0,0 @@
package fs
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
// Tests for YamlFilePath in yaml.go using subtests.
func TestYamlFilePath(t *testing.T) {
t.Run("CustomPath", func(t *testing.T) {
tmp := t.TempDir()
rel := filepath.Join(tmp, "custom", "config.yaml")
// Do not create the file; function should simply return Abs(customFileName).
expected := Abs(rel)
got := YamlFilePath("", "", rel)
assert.Equal(t, expected, got)
})
t.Run("PreferYmlIfExists", func(t *testing.T) {
dir := t.TempDir()
name := "app-config"
// Create .yml file
ymlPath := filepath.Join(dir, name+ExtYml)
err := os.WriteFile(ymlPath, []byte("foo: bar\n"), ModeFile)
if err != nil {
t.Fatalf("write %s: %v", ymlPath, err)
}
got := YamlFilePath(name, dir, "")
assert.Equal(t, ymlPath, got)
})
t.Run("DefaultYamlWhenYmlMissing", func(t *testing.T) {
dir := t.TempDir()
name := "settings"
// Ensure .yml does not exist; do not create it.
expected := filepath.Join(dir, name+ExtYaml)
got := YamlFilePath(name, dir, "")
assert.Equal(t, expected, got)
})
t.Run("BothExistReturnsYml", func(t *testing.T) {
dir := t.TempDir()
name := "prefs"
// Create both files
ymlPath := filepath.Join(dir, name+ExtYml)
yamlPath := filepath.Join(dir, name+ExtYaml)
if err := os.WriteFile(ymlPath, []byte("a: 1\n"), ModeFile); err != nil {
t.Fatalf("write %s: %v", ymlPath, err)
}
if err := os.WriteFile(yamlPath, []byte("a: 2\n"), ModeFile); err != nil {
t.Fatalf("write %s: %v", yamlPath, err)
}
got := YamlFilePath(name, dir, "")
assert.Equal(t, ymlPath, got)
})
}