Browse Source

refactor(config): rewrite config parser

This PR refactors the configuration parser, replacing the old parser implementation with a new, more structured approach that includes validation and improved organization.

Key changes:
- Complete rewrite of the configuration parser using a map-based structure with built-in validation
- Addition of comprehensive validator functions for configuration values
- Renamed numerous configuration getter methods for better consistency
Frédéric Guillot 6 months ago
parent
commit
5e607be86a

+ 1 - 1
internal/cli/cli.go

@@ -78,7 +78,7 @@ func Parse() {
 	flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
 	flag.Parse()
 
-	cfg := config.NewParser()
+	cfg := config.NewConfigParser()
 
 	if flagConfigFile != "" {
 		config.Opts, err = cfg.ParseFile(flagConfigFile)

+ 11 - 0
internal/config/config.go

@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package config // import "miniflux.app/v2/internal/config"
+
+import "miniflux.app/v2/internal/version"
+
+// Opts holds parsed configuration options.
+var Opts *configOptions
+
+var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"

+ 0 - 2367
internal/config/config_test.go

@@ -1,2367 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package config // import "miniflux.app/v2/internal/config"
-
-import (
-	"bytes"
-	"os"
-	"reflect"
-	"slices"
-	"testing"
-	"time"
-)
-
-func TestLogFileDefaultValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogFile() != defaultLogFile {
-		t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
-	}
-}
-
-func TestLogFileWithCustomFilename(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LOG_FILE", "foobar.log")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-	if opts.LogFile() != "foobar.log" {
-		t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
-	}
-}
-
-func TestLogFileWithEmptyValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LOG_FILE", "")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogFile() != defaultLogFile {
-		t.Fatalf(`Unexpected log file value, got %q`, opts.LogFile())
-	}
-}
-
-func TestLogLevelDefaultValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogLevel() != defaultLogLevel {
-		t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
-	}
-}
-
-func TestLogLevelWithCustomValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LOG_LEVEL", "warning")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogLevel() != "warning" {
-		t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
-	}
-}
-
-func TestLogLevelWithInvalidValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LOG_LEVEL", "invalid")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogLevel() != defaultLogLevel {
-		t.Fatalf(`Unexpected log level value, got %q`, opts.LogLevel())
-	}
-}
-
-func TestLogDateTimeDefaultValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogDateTime() != defaultLogDateTime {
-		t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
-	}
-}
-
-func TestLogDateTimeWithCustomValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LOG_DATETIME", "false")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogDateTime() != false {
-		t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
-	}
-}
-
-func TestLogDateTimeWithInvalidValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LOG_DATETIME", "invalid")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogDateTime() != defaultLogDateTime {
-		t.Fatalf(`Unexpected log date time value, got %v`, opts.LogDateTime())
-	}
-}
-
-func TestLogFormatDefaultValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogFormat() != defaultLogFormat {
-		t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
-	}
-}
-
-func TestLogFormatWithCustomValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LOG_FORMAT", "json")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogFormat() != "json" {
-		t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
-	}
-}
-
-func TestLogFormatWithInvalidValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LOG_FORMAT", "invalid")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogFormat() != defaultLogFormat {
-		t.Fatalf(`Unexpected log format value, got %q`, opts.LogFormat())
-	}
-}
-
-func TestCustomBaseURL(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("BASE_URL", "http://example.org")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.BaseURL() != "http://example.org" {
-		t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
-	}
-
-	if opts.RootURL() != "http://example.org" {
-		t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
-	}
-
-	if opts.BasePath() != "" {
-		t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
-	}
-}
-
-func TestCustomBaseURLWithTrailingSlash(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("BASE_URL", "http://example.org/folder/")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.BaseURL() != "http://example.org/folder" {
-		t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
-	}
-
-	if opts.RootURL() != "http://example.org" {
-		t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
-	}
-
-	if opts.BasePath() != "/folder" {
-		t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
-	}
-}
-
-func TestCustomBaseURLWithCustomPort(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("BASE_URL", "http://example.org:88/folder/")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.BaseURL() != "http://example.org:88/folder" {
-		t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
-	}
-
-	if opts.RootURL() != "http://example.org:88" {
-		t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
-	}
-
-	if opts.BasePath() != "/folder" {
-		t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
-	}
-}
-
-func TestBaseURLWithoutScheme(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("BASE_URL", "example.org/folder/")
-
-	_, err := NewParser().ParseEnvironmentVariables()
-	if err == nil {
-		t.Fatalf(`Parsing must fail`)
-	}
-}
-
-func TestBaseURLWithInvalidScheme(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("BASE_URL", "ftp://example.org/folder/")
-
-	_, err := NewParser().ParseEnvironmentVariables()
-	if err == nil {
-		t.Fatalf(`Parsing must fail`)
-	}
-}
-
-func TestInvalidBaseURL(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("BASE_URL", "http://example|org")
-
-	_, err := NewParser().ParseEnvironmentVariables()
-	if err == nil {
-		t.Fatalf(`Parsing must fail`)
-	}
-}
-
-func TestDefaultBaseURL(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.BaseURL() != defaultBaseURL {
-		t.Fatalf(`Unexpected base URL, got "%s"`, opts.BaseURL())
-	}
-
-	if opts.RootURL() != defaultBaseURL {
-		t.Fatalf(`Unexpected root URL, got "%s"`, opts.RootURL())
-	}
-
-	if opts.BasePath() != "" {
-		t.Fatalf(`Unexpected base path, got "%s"`, opts.BasePath())
-	}
-}
-
-func TestDatabaseURL(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("DATABASE_URL", "foobar")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "foobar"
-	result := opts.DatabaseURL()
-
-	if result != expected {
-		t.Errorf(`Unexpected DATABASE_URL value, got %q instead of %q`, result, expected)
-	}
-
-	if opts.IsDefaultDatabaseURL() {
-		t.Errorf(`This is not the default database URL and it should returns false`)
-	}
-}
-
-func TestDefaultDatabaseURLValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultDatabaseURL
-	result := opts.DatabaseURL()
-
-	if result != expected {
-		t.Errorf(`Unexpected DATABASE_URL value, got %q instead of %q`, result, expected)
-	}
-
-	if !opts.IsDefaultDatabaseURL() {
-		t.Errorf(`This is the default database URL and it should returns true`)
-	}
-}
-
-func TestDefaultDatabaseMaxConnsValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultDatabaseMaxConns
-	result := opts.DatabaseMaxConns()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DATABASE_MAX_CONNS value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDatabaseMaxConns(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("DATABASE_MAX_CONNS", "42")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 42
-	result := opts.DatabaseMaxConns()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DATABASE_MAX_CONNS value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDefaultDatabaseMinConnsValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultDatabaseMinConns
-	result := opts.DatabaseMinConns()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DATABASE_MIN_CONNS value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDatabaseMinConns(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("DATABASE_MIN_CONNS", "42")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 42
-	result := opts.DatabaseMinConns()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DATABASE_MIN_CONNS value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestListenAddr(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("LISTEN_ADDR", "foobar")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []string{"foobar"}
-	result := opts.ListenAddr()
-
-	if !reflect.DeepEqual(result, expected) {
-		t.Fatalf(`Unexpected LISTEN_ADDR value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestListenAddrWithPortDefined(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("PORT", "3000")
-	os.Setenv("LISTEN_ADDR", "foobar") // This should be overridden by PORT
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []string{":3000"}
-	result := opts.ListenAddr()
-
-	if !reflect.DeepEqual(result, expected) {
-		t.Fatalf(`Unexpected LISTEN_ADDR value when PORT is set, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDefaultListenAddrValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []string{defaultListenAddr}
-	result := opts.ListenAddr()
-
-	if !reflect.DeepEqual(result, expected) {
-		t.Fatalf(`Unexpected default LISTEN_ADDR value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestCertFile(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("CERT_FILE", "foobar")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "foobar"
-	result := opts.CertFile()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CERT_FILE value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultCertFileValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultCertFile
-	result := opts.CertFile()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CERT_FILE value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestKeyFile(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("KEY_FILE", "foobar")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "foobar"
-	result := opts.CertKeyFile()
-
-	if result != expected {
-		t.Fatalf(`Unexpected KEY_FILE value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultKeyFileValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultKeyFile
-	result := opts.CertKeyFile()
-
-	if result != expected {
-		t.Fatalf(`Unexpected KEY_FILE value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestCertDomain(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("CERT_DOMAIN", "example.org")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "example.org"
-	result := opts.CertDomain()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CERT_DOMAIN value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultCertDomainValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultCertDomain
-	result := opts.CertDomain()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CERT_DOMAIN value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultCleanupFrequencyHoursValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultCleanupFrequency
-	result := opts.CleanupFrequency()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CLEANUP_FREQUENCY_HOURS value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "CLEANUP_FREQUENCY_HOURS"
-	})
-
-	expectedSerialized := int(defaultCleanupFrequency / time.Hour)
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestCleanupFrequencyHours(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("CLEANUP_FREQUENCY_HOURS", "42")
-	os.Setenv("CLEANUP_FREQUENCY", "19")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 42 * time.Hour
-	result := opts.CleanupFrequency()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CLEANUP_FREQUENCY_HOURS value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "CLEANUP_FREQUENCY_HOURS"
-	})
-
-	expectedSerialized := 42
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultCleanupArchiveReadDaysValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 60 * 24 * time.Hour
-	result := opts.CleanupArchiveReadInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CLEANUP_ARCHIVE_READ_DAYS value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "CLEANUP_ARCHIVE_READ_DAYS"
-	})
-
-	expectedSerialized := 60
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestCleanupArchiveReadDays(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("CLEANUP_ARCHIVE_READ_DAYS", "7")
-	os.Setenv("ARCHIVE_READ_DAYS", "19")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 7 * 24 * time.Hour
-	result := opts.CleanupArchiveReadInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CLEANUP_ARCHIVE_READ_DAYS value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "CLEANUP_ARCHIVE_READ_DAYS"
-	})
-
-	expectedSerialized := 7
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultCleanupRemoveSessionsDaysValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 30 * 24 * time.Hour
-	result := opts.CleanupRemoveSessionsInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CLEANUP_REMOVE_SESSIONS_DAYS value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "CLEANUP_REMOVE_SESSIONS_DAYS"
-	})
-
-	expectedSerialized := 30
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestCleanupRemoveSessionsDays(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("CLEANUP_REMOVE_SESSIONS_DAYS", "7")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 7 * 24 * time.Hour
-	result := opts.CleanupRemoveSessionsInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CLEANUP_REMOVE_SESSIONS_DAYS value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "CLEANUP_REMOVE_SESSIONS_DAYS"
-	})
-
-	expectedSerialized := 7
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultWorkerPoolSizeValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultWorkerPoolSize
-	result := opts.WorkerPoolSize()
-
-	if result != expected {
-		t.Fatalf(`Unexpected WORKER_POOL_SIZE value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestWorkerPoolSize(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("WORKER_POOL_SIZE", "42")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 42
-	result := opts.WorkerPoolSize()
-
-	if result != expected {
-		t.Fatalf(`Unexpected WORKER_POOL_SIZE value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDefaultPollingFrequencyValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultPollingFrequency
-	result := opts.PollingFrequency()
-
-	if result != expected {
-		t.Fatalf(`Unexpected POLLING_FREQUENCY value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "POLLING_FREQUENCY"
-	})
-
-	expectedSerialized := int(defaultPollingFrequency / time.Minute)
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestPollingFrequency(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("POLLING_FREQUENCY", "42")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 42 * time.Minute
-	result := opts.PollingFrequency()
-
-	if result != expected {
-		t.Fatalf(`Unexpected POLLING_FREQUENCY value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "POLLING_FREQUENCY"
-	})
-
-	expectedSerialized := 42
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultForceRefreshInterval(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultForceRefreshInterval
-	result := opts.ForceRefreshInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected FORCE_REFRESH_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestForceRefreshInterval(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("FORCE_REFRESH_INTERVAL", "42")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 42 * time.Minute
-	result := opts.ForceRefreshInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected FORCE_REFRESH_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "FORCE_REFRESH_INTERVAL"
-	})
-
-	expectedSerialized := 42
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultBatchSizeValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultBatchSize
-	result := opts.BatchSize()
-
-	if result != expected {
-		t.Fatalf(`Unexpected BATCH_SIZE value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestBatchSize(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("BATCH_SIZE", "42")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 42
-	result := opts.BatchSize()
-
-	if result != expected {
-		t.Fatalf(`Unexpected BATCH_SIZE value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDefaultPollingSchedulerValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultPollingScheduler
-	result := opts.PollingScheduler()
-
-	if result != expected {
-		t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestPollingScheduler(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("POLLING_SCHEDULER", "entry_count_based")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "entry_count_based"
-	result := opts.PollingScheduler()
-
-	if result != expected {
-		t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDefaultSchedulerEntryFrequencyMaxIntervalValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultSchedulerEntryFrequencyMaxInterval
-	result := opts.SchedulerEntryFrequencyMaxInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestSchedulerEntryFrequencyMaxInterval(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL", "30")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 30 * time.Minute
-	result := opts.SchedulerEntryFrequencyMaxInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL"
-	})
-
-	expectedSerialized := 30
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultSchedulerEntryFrequencyMinIntervalValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultSchedulerEntryFrequencyMinInterval
-	result := opts.SchedulerEntryFrequencyMinInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestSchedulerEntryFrequencyMinInterval(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", "30")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 30 * time.Minute
-	result := opts.SchedulerEntryFrequencyMinInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL"
-	})
-
-	expectedSerialized := 30
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultSchedulerEntryFrequencyFactorValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultSchedulerEntryFrequencyFactor
-	result := opts.SchedulerEntryFrequencyFactor()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_FACTOR value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestSchedulerEntryFrequencyFactor(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", "2")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 2
-	result := opts.SchedulerEntryFrequencyFactor()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ENTRY_FREQUENCY_FACTOR value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDefaultSchedulerRoundRobinValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultSchedulerRoundRobinMinInterval
-	result := opts.SchedulerRoundRobinMinInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestSchedulerRoundRobin(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", "15")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 15 * time.Minute
-	result := opts.SchedulerRoundRobinMinInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL"
-	})
-
-	expectedSerialized := 15
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultSchedulerRoundRobinMaxIntervalValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultSchedulerRoundRobinMaxInterval
-	result := opts.SchedulerRoundRobinMaxInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestSchedulerRoundRobinMaxInterval(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("SCHEDULER_ROUND_ROBIN_MAX_INTERVAL", "150")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 150 * time.Minute
-	result := opts.SchedulerRoundRobinMaxInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL value, got %v instead of %v`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL"
-	})
-
-	expectedSerialized := 150
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestPollingParsingErrorLimit(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("POLLING_PARSING_ERROR_LIMIT", "100")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 100
-	result := opts.PollingParsingErrorLimit()
-
-	if result != expected {
-		t.Fatalf(`Unexpected POLLING_SCHEDULER value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestOAuth2UserCreationWhenUnset(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := false
-	result := opts.IsOAuth2UserCreationAllowed()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_USER_CREATION value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestOAuth2UserCreationAdmin(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("OAUTH2_USER_CREATION", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.IsOAuth2UserCreationAllowed()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_USER_CREATION value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestOAuth2ClientID(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("OAUTH2_CLIENT_ID", "foobar")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "foobar"
-	result := opts.OAuth2ClientID()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_CLIENT_ID value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultOAuth2ClientIDValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultOAuth2ClientID
-	result := opts.OAuth2ClientID()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_CLIENT_ID value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestOAuth2ClientSecret(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("OAUTH2_CLIENT_SECRET", "secret")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "secret"
-	result := opts.OAuth2ClientSecret()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_CLIENT_SECRET value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultOAuth2ClientSecretValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultOAuth2ClientSecret
-	result := opts.OAuth2ClientSecret()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_CLIENT_SECRET value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestOAuth2RedirectURL(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("OAUTH2_REDIRECT_URL", "http://example.org")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "http://example.org"
-	result := opts.OAuth2RedirectURL()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_REDIRECT_URL value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultOAuth2RedirectURLValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultOAuth2RedirectURL
-	result := opts.OAuth2RedirectURL()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_REDIRECT_URL value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestOAuth2OIDCDiscoveryEndpoint(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("OAUTH2_OIDC_DISCOVERY_ENDPOINT", "http://example.org")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "http://example.org"
-	result := opts.OIDCDiscoveryEndpoint()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_OIDC_DISCOVERY_ENDPOINT value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultOIDCDiscoveryEndpointValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultOAuth2OidcDiscoveryEndpoint
-	result := opts.OIDCDiscoveryEndpoint()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_OIDC_DISCOVERY_ENDPOINT value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestOAuth2Provider(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("OAUTH2_PROVIDER", "google")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "google"
-	result := opts.OAuth2Provider()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_PROVIDER value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultOAuth2ProviderValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultOAuth2Provider
-	result := opts.OAuth2Provider()
-
-	if result != expected {
-		t.Fatalf(`Unexpected OAUTH2_PROVIDER value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestHSTSWhenUnset(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.HasHSTS()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DISABLE_HSTS value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestHSTS(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("DISABLE_HSTS", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := false
-	result := opts.HasHSTS()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DISABLE_HSTS value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDisableHTTPServiceWhenUnset(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.HasHTTPService()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DISABLE_HTTP_SERVICE value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDisableHTTPService(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("DISABLE_HTTP_SERVICE", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := false
-	result := opts.HasHTTPService()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DISABLE_HTTP_SERVICE value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDisableSchedulerServiceWhenUnset(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.HasSchedulerService()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DISABLE_SCHEDULER_SERVICE value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDisableSchedulerService(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("DISABLE_SCHEDULER_SERVICE", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := false
-	result := opts.HasSchedulerService()
-
-	if result != expected {
-		t.Fatalf(`Unexpected DISABLE_SCHEDULER_SERVICE value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestRunMigrationsWhenUnset(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := false
-	result := opts.RunMigrations()
-
-	if result != expected {
-		t.Fatalf(`Unexpected RUN_MIGRATIONS value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestRunMigrations(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("RUN_MIGRATIONS", "yes")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.RunMigrations()
-
-	if result != expected {
-		t.Fatalf(`Unexpected RUN_MIGRATIONS value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestCreateAdminWhenUnset(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := false
-	result := opts.CreateAdmin()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CREATE_ADMIN value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestCreateAdmin(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("CREATE_ADMIN", "true")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.CreateAdmin()
-
-	if result != expected {
-		t.Fatalf(`Unexpected CREATE_ADMIN value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestMediaProxyMode(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MEDIA_PROXY_MODE", "all")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "all"
-	result := opts.MediaProxyMode()
-
-	if result != expected {
-		t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultMediaProxyModeValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultMediaProxyMode
-	result := opts.MediaProxyMode()
-
-	if result != expected {
-		t.Fatalf(`Unexpected MEDIA_PROXY_MODE value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestMediaProxyResourceTypes(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []string{"audio", "image"}
-
-	if len(expected) != len(opts.MediaProxyResourceTypes()) {
-		t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
-	}
-
-	resultMap := make(map[string]bool)
-	for _, mediaType := range opts.MediaProxyResourceTypes() {
-		resultMap[mediaType] = true
-	}
-
-	for _, mediaType := range expected {
-		if !resultMap[mediaType] {
-			t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
-		}
-	}
-}
-
-func TestMediaProxyResourceTypesWithDuplicatedValues(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image,audio, image")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []string{"audio", "image"}
-	if len(expected) != len(opts.MediaProxyResourceTypes()) {
-		t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
-	}
-
-	resultMap := make(map[string]bool)
-	for _, mediaType := range opts.MediaProxyResourceTypes() {
-		resultMap[mediaType] = true
-	}
-
-	for _, mediaType := range expected {
-		if !resultMap[mediaType] {
-			t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
-		}
-	}
-}
-
-func TestDefaultMediaProxyResourceTypes(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []string{"image"}
-
-	if len(expected) != len(opts.MediaProxyResourceTypes()) {
-		t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
-	}
-
-	resultMap := make(map[string]bool)
-	for _, mediaType := range opts.MediaProxyResourceTypes() {
-		resultMap[mediaType] = true
-	}
-
-	for _, mediaType := range expected {
-		if !resultMap[mediaType] {
-			t.Fatalf(`Unexpected MEDIA_PROXY_RESOURCE_TYPES value, got %v instead of %v`, opts.MediaProxyResourceTypes(), expected)
-		}
-	}
-}
-
-func TestMediaProxyHTTPClientTimeout(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MEDIA_PROXY_HTTP_CLIENT_TIMEOUT", "24")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 24 * time.Second
-	result := opts.MediaProxyHTTPClientTimeout()
-
-	if result != expected {
-		t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"
-	})
-
-	expectedSerialized := 24
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultMediaProxyHTTPClientTimeoutValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultMediaProxyHTTPClientTimeout
-	result := opts.MediaProxyHTTPClientTimeout()
-
-	if result != expected {
-		t.Fatalf(`Unexpected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"
-	})
-
-	expectedSerialized := int(defaultMediaProxyHTTPClientTimeout / time.Second)
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestMediaProxyCustomURL(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://example.org/proxy")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-	expected := "http://example.org/proxy"
-	result := opts.MediaCustomProxyURL()
-
-	if result == nil || result.String() != expected {
-		t.Fatalf(`Unexpected MEDIA_PROXY_CUSTOM_URL value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestMediaProxyPrivateKey(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "foobar")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []byte("foobar")
-	result := opts.MediaProxyPrivateKey()
-
-	if !bytes.Equal(result, expected) {
-		t.Fatalf(`Unexpected MEDIA_PROXY_PRIVATE_KEY value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestHTTPSOff(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if opts.HTTPS {
-		t.Fatalf(`Unexpected HTTPS value, got "%v"`, opts.HTTPS)
-	}
-}
-
-func TestHTTPSOn(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("HTTPS", "on")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	if !opts.HTTPS {
-		t.Fatalf(`Unexpected HTTPS value, got "%v"`, opts.HTTPS)
-	}
-}
-
-func TestHTTPClientTimeout(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("HTTP_CLIENT_TIMEOUT", "42")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 42 * time.Second
-	result := opts.HTTPClientTimeout()
-
-	if result != expected {
-		t.Fatalf(`Unexpected HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "HTTP_CLIENT_TIMEOUT"
-	})
-
-	expectedSerialized := 42
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultHTTPClientTimeoutValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultHTTPClientTimeout
-	result := opts.HTTPClientTimeout()
-
-	if result != expected {
-		t.Fatalf(`Unexpected HTTP_CLIENT_TIMEOUT value, got %d instead of %d`, result, expected)
-	}
-}
-
-func TestHTTPClientMaxBodySize(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("HTTP_CLIENT_MAX_BODY_SIZE", "42")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := int64(42 * 1024 * 1024)
-	result := opts.HTTPClientMaxBodySize()
-
-	if result != expected {
-		t.Fatalf(`Unexpected HTTP_CLIENT_MAX_BODY_SIZE value, got %d instead of %d`, result, expected)
-	}
-}
-
-func TestDefaultHTTPClientMaxBodySizeValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := int64(defaultHTTPClientMaxBodySize * 1024 * 1024)
-	result := opts.HTTPClientMaxBodySize()
-
-	if result != expected {
-		t.Fatalf(`Unexpected HTTP_CLIENT_MAX_BODY_SIZE value, got %d instead of %d`, result, expected)
-	}
-}
-
-func TestHTTPServerTimeout(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("HTTP_SERVER_TIMEOUT", "342")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 342 * time.Second
-	result := opts.HTTPServerTimeout()
-
-	if result != expected {
-		t.Fatalf(`Unexpected HTTP_SERVER_TIMEOUT value, got %d instead of %d`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "HTTP_SERVER_TIMEOUT"
-	})
-
-	expectedSerialized := 342
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultHTTPServerTimeoutValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultHTTPServerTimeout
-	result := opts.HTTPServerTimeout()
-
-	if result != expected {
-		t.Fatalf(`Unexpected HTTP_SERVER_TIMEOUT value, got %d instead of %d`, result, expected)
-	}
-}
-
-func TestParseConfigFile(t *testing.T) {
-	content := []byte(`
- # This is a comment
-
-LOG_LEVEL = debug
-
-Invalid text
-`)
-
-	tmpfile, err := os.CreateTemp(".", "miniflux.*.unit_test.conf")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if _, err := tmpfile.Write(content); err != nil {
-		t.Fatal(err)
-	}
-
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseFile(tmpfile.Name())
-	if err != nil {
-		t.Errorf(`Parsing failure: %v`, err)
-	}
-
-	if opts.LogLevel() != "debug" {
-		t.Errorf(`Unexpected log level value, got %q`, opts.LogLevel())
-	}
-
-	if err := tmpfile.Close(); err != nil {
-		t.Fatal(err)
-	}
-
-	if err := os.Remove(tmpfile.Name()); err != nil {
-		t.Fatal(err)
-	}
-}
-
-func TestAuthProxyHeader(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("AUTH_PROXY_HEADER", "X-Forwarded-User")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "X-Forwarded-User"
-	result := opts.AuthProxyHeader()
-
-	if result != expected {
-		t.Fatalf(`Unexpected AUTH_PROXY_HEADER value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestDefaultAuthProxyHeaderValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultAuthProxyHeader
-	result := opts.AuthProxyHeader()
-
-	if result != expected {
-		t.Fatalf(`Unexpected AUTH_PROXY_HEADER value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestAuthProxyUserCreationWhenUnset(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := false
-	result := opts.IsAuthProxyUserCreationAllowed()
-
-	if result != expected {
-		t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestAuthProxyUserCreationAdmin(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("AUTH_PROXY_USER_CREATION", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.IsAuthProxyUserCreationAllowed()
-
-	if result != expected {
-		t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestFetchBilibiliWatchTime(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("FETCH_BILIBILI_WATCH_TIME", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.FetchBilibiliWatchTime()
-
-	if result != expected {
-		t.Fatalf(`Unexpected FETCH_BILIBILI_WATCH_TIME value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestFetchNebulaWatchTime(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("FETCH_NEBULA_WATCH_TIME", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.FetchNebulaWatchTime()
-
-	if result != expected {
-		t.Fatalf(`Unexpected FETCH_NEBULA_WATCH_TIME value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestFetchOdyseeWatchTime(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("FETCH_ODYSEE_WATCH_TIME", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.FetchOdyseeWatchTime()
-
-	if result != expected {
-		t.Fatalf(`Unexpected FETCH_ODYSEE_WATCH_TIME value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestFetchYouTubeWatchTime(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("FETCH_YOUTUBE_WATCH_TIME", "1")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := true
-	result := opts.FetchYouTubeWatchTime()
-
-	if result != expected {
-		t.Fatalf(`Unexpected FETCH_YOUTUBE_WATCH_TIME value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestYouTubeApiKey(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("YOUTUBE_API_KEY", "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "AAAAAAAAAAAAAaaaaaaaaaaaaa0000000000000"
-	result := opts.YouTubeApiKey()
-
-	if result != expected {
-		t.Fatalf(`Unexpected YOUTUBE_API_KEY value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestDefaultYouTubeEmbedUrl(t *testing.T) {
-	os.Clearenv()
-
-	opts, err := NewParser().ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "https://www.youtube-nocookie.com/embed/"
-	result := opts.YouTubeEmbedUrlOverride()
-
-	if result != expected {
-		t.Fatalf(`Unexpected default value, got %v instead of %v`, result, expected)
-	}
-
-	expected = "www.youtube-nocookie.com"
-	result = opts.YouTubeEmbedDomain()
-	if result != expected {
-		t.Fatalf(`Unexpected YouTube embed domain, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestYouTubeEmbedUrlOverride(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
-
-	opts, err := NewParser().ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "https://invidious.custom/embed/"
-	result := opts.YouTubeEmbedUrlOverride()
-
-	if result != expected {
-		t.Fatalf(`Unexpected YOUTUBE_EMBED_URL_OVERRIDE value, got %v instead of %v`, result, expected)
-	}
-
-	expected = "invidious.custom"
-	result = opts.YouTubeEmbedDomain()
-	if result != expected {
-		t.Fatalf(`Unexpected YouTube embed domain, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestParseConfigDumpOutput(t *testing.T) {
-	os.Clearenv()
-
-	wantOpts := NewOptions()
-	wantOpts.adminUsername = "my-username"
-
-	serialized := wantOpts.String()
-	tmpfile, err := os.CreateTemp(".", "miniflux.*.unit_test.conf")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if _, err := tmpfile.WriteString(serialized); err != nil {
-		t.Fatal(err)
-	}
-
-	parser := NewParser()
-	parsedOpts, err := parser.ParseFile(tmpfile.Name())
-	if err != nil {
-		t.Errorf(`Parsing failure: %v`, err)
-	}
-
-	if parsedOpts.AdminUsername() != wantOpts.AdminUsername() {
-		t.Fatalf(`Unexpected ADMIN_USERNAME value, got %q instead of %q`, parsedOpts.AdminUsername(), wantOpts.AdminUsername())
-	}
-
-	if err := tmpfile.Close(); err != nil {
-		t.Fatal(err)
-	}
-
-	if err := os.Remove(tmpfile.Name()); err != nil {
-		t.Fatal(err)
-	}
-}
-
-func TestHTTPClientProxies(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("HTTP_CLIENT_PROXIES", "http://proxy1.example.com,http://proxy2.example.com")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []string{"http://proxy1.example.com", "http://proxy2.example.com"}
-	result := opts.HTTPClientProxies()
-
-	if len(expected) != len(result) {
-		t.Fatalf(`Unexpected HTTP_CLIENT_PROXIES value, got %v instead of %v`, result, expected)
-	}
-
-	for i, proxy := range expected {
-		if result[i] != proxy {
-			t.Fatalf(`Unexpected HTTP_CLIENT_PROXIES value at index %d, got %q instead of %q`, i, result[i], proxy)
-		}
-	}
-}
-
-func TestDefaultHTTPClientProxiesValue(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := []string{}
-	result := opts.HTTPClientProxies()
-
-	if len(expected) != len(result) {
-		t.Fatalf(`Unexpected default HTTP_CLIENT_PROXIES value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestHTTPClientProxy(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("HTTP_CLIENT_PROXY", "http://proxy.example.com")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := "http://proxy.example.com"
-	if opts.HTTPClientProxyURL() == nil || opts.HTTPClientProxyURL().String() != expected {
-		t.Fatalf(`Unexpected HTTP_CLIENT_PROXY value, got %v instead of %v`, opts.HTTPClientProxyURL(), expected)
-	}
-}
-
-func TestInvalidHTTPClientProxy(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("HTTP_CLIENT_PROXY", "sche|me://invalid-proxy-url")
-
-	parser := NewParser()
-	_, err := parser.ParseEnvironmentVariables()
-	if err == nil {
-		t.Fatalf(`Expected error for invalid HTTP_CLIENT_PROXY value, but got none`)
-	}
-}
-
-func TestDefaultPollingLimitPerHost(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 0
-	result := opts.PollingLimitPerHost()
-	if result != expected {
-		t.Fatalf(`Unexpected default PollingLimitPerHost value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestCustomPollingLimitPerHost(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("POLLING_LIMIT_PER_HOST", "10")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 10
-	result := opts.PollingLimitPerHost()
-	if result != expected {
-		t.Fatalf(`Unexpected custom PollingLimitPerHost value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestMetricsRefreshInterval(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("METRICS_REFRESH_INTERVAL", "33")
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := 33 * time.Second
-	result := opts.MetricsRefreshInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected METRICS_REFRESH_INTERVAL value, got %d instead of %d`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "METRICS_REFRESH_INTERVAL"
-	})
-
-	expectedSerialized := 33
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}
-
-func TestDefaultMetricsRefreshInterval(t *testing.T) {
-	os.Clearenv()
-
-	parser := NewParser()
-	opts, err := parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	expected := defaultMetricsRefreshInterval
-	result := opts.MetricsRefreshInterval()
-
-	if result != expected {
-		t.Fatalf(`Unexpected METRICS_REFRESH_INTERVAL value, got %d instead of %d`, result, expected)
-	}
-
-	sorted := opts.SortedOptions(false)
-	i := slices.IndexFunc(sorted, func(opt *option) bool {
-		return opt.Key == "METRICS_REFRESH_INTERVAL"
-	})
-
-	expectedSerialized := int(defaultMetricsRefreshInterval / time.Second)
-	if got := sorted[i].Value; got != expectedSerialized {
-		t.Fatalf(`Unexpected value in option output, got %q instead of %q`, got, expectedSerialized)
-	}
-}

+ 793 - 620
internal/config/options.go

@@ -10,811 +10,984 @@ import (
 	"slices"
 	"strings"
 	"time"
-
-	"miniflux.app/v2/internal/crypto"
-	"miniflux.app/v2/internal/version"
 )
 
+type optionPair struct {
+	Key   string
+	Value string
+}
+
+type configValueType int
+
 const (
-	defaultHTTPS                              = false
-	defaultLogFile                            = "stderr"
-	defaultLogDateTime                        = false
-	defaultLogFormat                          = "text"
-	defaultLogLevel                           = "info"
-	defaultHSTS                               = true
-	defaultHTTPService                        = true
-	defaultSchedulerService                   = true
-	defaultBaseURL                            = "http://localhost"
-	defaultRootURL                            = "http://localhost"
-	defaultBasePath                           = ""
-	defaultWorkerPoolSize                     = 16
-	defaultPollingFrequency                   = 60 * time.Minute
-	defaultForceRefreshInterval               = 30 * time.Minute
-	defaultBatchSize                          = 100
-	defaultPollingScheduler                   = "round_robin"
-	defaultSchedulerEntryFrequencyMinInterval = 5 * time.Minute
-	defaultSchedulerEntryFrequencyMaxInterval = 24 * time.Hour
-	defaultSchedulerEntryFrequencyFactor      = 1
-	defaultSchedulerRoundRobinMinInterval     = 1 * time.Hour
-	defaultSchedulerRoundRobinMaxInterval     = 24 * time.Hour
-	defaultPollingParsingErrorLimit           = 3
-	defaultRunMigrations                      = false
-	defaultDatabaseURL                        = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
-	defaultDatabaseMaxConns                   = 20
-	defaultDatabaseMinConns                   = 1
-	defaultDatabaseConnectionLifetime         = 5
-	defaultListenAddr                         = "127.0.0.1:8080"
-	defaultCertFile                           = ""
-	defaultKeyFile                            = ""
-	defaultCertDomain                         = ""
-	defaultCleanupFrequency                   = 24 * time.Hour
-	defaultCleanupArchiveReadInterval         = 60 * 24 * time.Hour
-	defaultCleanupArchiveUnreadInterval       = 180 * 24 * time.Hour
-	defaultCleanupArchiveBatchSize            = 10000
-	defaultCleanupRemoveSessionsInterval      = 30 * 24 * time.Hour
-	defaultMediaProxyHTTPClientTimeout        = 120 * time.Second
-	defaultMediaProxyMode                     = "http-only"
-	defaultMediaResourceTypes                 = "image"
-	defaultMediaProxyURL                      = ""
-	defaultFilterEntryMaxAgeDays              = 0
-	defaultFetchBilibiliWatchTime             = false
-	defaultFetchNebulaWatchTime               = false
-	defaultFetchOdyseeWatchTime               = false
-	defaultFetchYouTubeWatchTime              = false
-	defaultYouTubeApiKey                      = ""
-	defaultYouTubeEmbedUrlOverride            = "https://www.youtube-nocookie.com/embed/"
-	defaultCreateAdmin                        = false
-	defaultAdminUsername                      = ""
-	defaultAdminPassword                      = ""
-	defaultOAuth2UserCreation                 = false
-	defaultOAuth2ClientID                     = ""
-	defaultOAuth2ClientSecret                 = ""
-	defaultOAuth2RedirectURL                  = ""
-	defaultOAuth2OidcDiscoveryEndpoint        = ""
-	defaultOauth2OidcProviderName             = "OpenID Connect"
-	defaultOAuth2Provider                     = ""
-	defaultDisableLocalAuth                   = false
-	defaultHTTPClientTimeout                  = 20 * time.Second
-	defaultHTTPClientMaxBodySize              = 15
-	defaultHTTPClientProxy                    = ""
-	defaultHTTPServerTimeout                  = 300 * time.Second
-	defaultAuthProxyHeader                    = ""
-	defaultAuthProxyUserCreation              = false
-	defaultMaintenanceMode                    = false
-	defaultMaintenanceMessage                 = "Miniflux is currently under maintenance"
-	defaultMetricsCollector                   = false
-	defaultMetricsRefreshInterval             = 60 * time.Second
-	defaultMetricsAllowedNetworks             = "127.0.0.1/8"
-	defaultMetricsUsername                    = ""
-	defaultMetricsPassword                    = ""
-	defaultWatchdog                           = true
-	defaultInvidiousInstance                  = "yewtu.be"
-	defaultWebAuthn                           = false
+	stringType configValueType = iota
+	stringListType
+	boolType
+	intType
+	int64Type
+	urlType
+	secondType
+	minuteType
+	hourType
+	dayType
+	secretFileType
+	bytesType
 )
 
-var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
-
-// option contains a key to value map of a single option. It may be used to output debug strings.
-type option struct {
-	Key   string
-	Value any
-}
-
-// Opts holds parsed configuration options.
-var Opts *options
-
-// options contains configuration options.
-type options struct {
-	HTTPS                              bool
-	logFile                            string
-	logDateTime                        bool
-	logFormat                          string
-	logLevel                           string
-	hsts                               bool
-	httpService                        bool
-	schedulerService                   bool
-	baseURL                            string
-	rootURL                            string
-	basePath                           string
-	databaseURL                        string
-	databaseMaxConns                   int
-	databaseMinConns                   int
-	databaseConnectionLifetime         int
-	runMigrations                      bool
-	listenAddr                         []string
-	certFile                           string
-	certDomain                         string
-	certKeyFile                        string
-	cleanupFrequencyInterval           time.Duration
-	cleanupArchiveReadInterval         time.Duration
-	cleanupArchiveUnreadInterval       time.Duration
-	cleanupArchiveBatchSize            int
-	cleanupRemoveSessionsInterval      time.Duration
-	forceRefreshInterval               time.Duration
-	batchSize                          int
-	schedulerEntryFrequencyMinInterval time.Duration
-	schedulerEntryFrequencyMaxInterval time.Duration
-	schedulerEntryFrequencyFactor      int
-	schedulerRoundRobinMinInterval     time.Duration
-	schedulerRoundRobinMaxInterval     time.Duration
-	pollingFrequency                   time.Duration
-	pollingLimitPerHost                int
-	pollingParsingErrorLimit           int
-	pollingScheduler                   string
-	workerPoolSize                     int
-	createAdmin                        bool
-	adminUsername                      string
-	adminPassword                      string
-	mediaProxyHTTPClientTimeout        time.Duration
-	mediaProxyMode                     string
-	mediaProxyResourceTypes            []string
-	mediaProxyCustomURL                *url.URL
-	fetchBilibiliWatchTime             bool
-	fetchNebulaWatchTime               bool
-	fetchOdyseeWatchTime               bool
-	fetchYouTubeWatchTime              bool
-	filterEntryMaxAgeDays              int
-	youTubeApiKey                      string
-	youTubeEmbedUrlOverride            string
-	youTubeEmbedDomain                 string
-	oauth2UserCreationAllowed          bool
-	oauth2ClientID                     string
-	oauth2ClientSecret                 string
-	oauth2RedirectURL                  string
-	oidcDiscoveryEndpoint              string
-	oidcProviderName                   string
-	oauth2Provider                     string
-	disableLocalAuth                   bool
-	httpClientTimeout                  time.Duration
-	httpClientMaxBodySize              int64
-	httpClientProxyURL                 *url.URL
-	httpClientProxies                  []string
-	httpClientUserAgent                string
-	httpServerTimeout                  time.Duration
-	authProxyHeader                    string
-	authProxyUserCreation              bool
-	maintenanceMode                    bool
-	maintenanceMessage                 string
-	metricsCollector                   bool
-	metricsRefreshInterval             time.Duration
-	metricsAllowedNetworks             []string
-	metricsUsername                    string
-	metricsPassword                    string
-	watchdog                           bool
-	invidiousInstance                  string
-	mediaProxyPrivateKey               []byte
-	webAuthn                           bool
-}
-
-// NewOptions returns Options with default values.
-func NewOptions() *options {
-	return &options{
-		HTTPS:                              defaultHTTPS,
-		logFile:                            defaultLogFile,
-		logDateTime:                        defaultLogDateTime,
-		logFormat:                          defaultLogFormat,
-		logLevel:                           defaultLogLevel,
-		hsts:                               defaultHSTS,
-		httpService:                        defaultHTTPService,
-		schedulerService:                   defaultSchedulerService,
-		baseURL:                            defaultBaseURL,
-		rootURL:                            defaultRootURL,
-		basePath:                           defaultBasePath,
-		databaseURL:                        defaultDatabaseURL,
-		databaseMaxConns:                   defaultDatabaseMaxConns,
-		databaseMinConns:                   defaultDatabaseMinConns,
-		databaseConnectionLifetime:         defaultDatabaseConnectionLifetime,
-		runMigrations:                      defaultRunMigrations,
-		listenAddr:                         []string{defaultListenAddr},
-		certFile:                           defaultCertFile,
-		certDomain:                         defaultCertDomain,
-		certKeyFile:                        defaultKeyFile,
-		cleanupFrequencyInterval:           defaultCleanupFrequency,
-		cleanupArchiveReadInterval:         defaultCleanupArchiveReadInterval,
-		cleanupArchiveUnreadInterval:       defaultCleanupArchiveUnreadInterval,
-		cleanupArchiveBatchSize:            defaultCleanupArchiveBatchSize,
-		cleanupRemoveSessionsInterval:      defaultCleanupRemoveSessionsInterval,
-		pollingFrequency:                   defaultPollingFrequency,
-		forceRefreshInterval:               defaultForceRefreshInterval,
-		batchSize:                          defaultBatchSize,
-		pollingScheduler:                   defaultPollingScheduler,
-		schedulerEntryFrequencyMinInterval: defaultSchedulerEntryFrequencyMinInterval,
-		schedulerEntryFrequencyMaxInterval: defaultSchedulerEntryFrequencyMaxInterval,
-		schedulerEntryFrequencyFactor:      defaultSchedulerEntryFrequencyFactor,
-		schedulerRoundRobinMinInterval:     defaultSchedulerRoundRobinMinInterval,
-		schedulerRoundRobinMaxInterval:     defaultSchedulerRoundRobinMaxInterval,
-		pollingParsingErrorLimit:           defaultPollingParsingErrorLimit,
-		workerPoolSize:                     defaultWorkerPoolSize,
-		createAdmin:                        defaultCreateAdmin,
-		mediaProxyHTTPClientTimeout:        defaultMediaProxyHTTPClientTimeout,
-		mediaProxyMode:                     defaultMediaProxyMode,
-		mediaProxyResourceTypes:            []string{defaultMediaResourceTypes},
-		mediaProxyCustomURL:                nil,
-		filterEntryMaxAgeDays:              defaultFilterEntryMaxAgeDays,
-		fetchBilibiliWatchTime:             defaultFetchBilibiliWatchTime,
-		fetchNebulaWatchTime:               defaultFetchNebulaWatchTime,
-		fetchOdyseeWatchTime:               defaultFetchOdyseeWatchTime,
-		fetchYouTubeWatchTime:              defaultFetchYouTubeWatchTime,
-		youTubeApiKey:                      defaultYouTubeApiKey,
-		youTubeEmbedUrlOverride:            defaultYouTubeEmbedUrlOverride,
-		oauth2UserCreationAllowed:          defaultOAuth2UserCreation,
-		oauth2ClientID:                     defaultOAuth2ClientID,
-		oauth2ClientSecret:                 defaultOAuth2ClientSecret,
-		oauth2RedirectURL:                  defaultOAuth2RedirectURL,
-		oidcDiscoveryEndpoint:              defaultOAuth2OidcDiscoveryEndpoint,
-		oidcProviderName:                   defaultOauth2OidcProviderName,
-		oauth2Provider:                     defaultOAuth2Provider,
-		disableLocalAuth:                   defaultDisableLocalAuth,
-		httpClientTimeout:                  defaultHTTPClientTimeout,
-		httpClientMaxBodySize:              defaultHTTPClientMaxBodySize * 1024 * 1024,
-		httpClientProxyURL:                 nil,
-		httpClientProxies:                  []string{},
-		httpClientUserAgent:                defaultHTTPClientUserAgent,
-		httpServerTimeout:                  defaultHTTPServerTimeout,
-		authProxyHeader:                    defaultAuthProxyHeader,
-		authProxyUserCreation:              defaultAuthProxyUserCreation,
-		maintenanceMode:                    defaultMaintenanceMode,
-		maintenanceMessage:                 defaultMaintenanceMessage,
-		metricsCollector:                   defaultMetricsCollector,
-		metricsRefreshInterval:             defaultMetricsRefreshInterval,
-		metricsAllowedNetworks:             []string{defaultMetricsAllowedNetworks},
-		metricsUsername:                    defaultMetricsUsername,
-		metricsPassword:                    defaultMetricsPassword,
-		watchdog:                           defaultWatchdog,
-		invidiousInstance:                  defaultInvidiousInstance,
-		mediaProxyPrivateKey:               crypto.GenerateRandomBytes(16),
-		webAuthn:                           defaultWebAuthn,
+type configValue struct {
+	ParsedStringValue string
+	ParsedBoolValue   bool
+	ParsedIntValue    int
+	ParsedInt64Value  int64
+	ParsedDuration    time.Duration
+	ParsedStringList  []string
+	ParsedURLValue    *url.URL
+	ParsedBytesValue  []byte
+
+	RawValue  string
+	ValueType configValueType
+	Secret    bool
+	TargetKey string
+
+	Validator func(string) error
+}
+
+type configOptions struct {
+	rootURL            string
+	basePath           string
+	youTubeEmbedDomain string
+	options            map[string]*configValue
+}
+
+// NewConfigOptions creates a new instance of ConfigOptions with default values.
+func NewConfigOptions() *configOptions {
+	return &configOptions{
+		rootURL:            "http://localhost",
+		basePath:           "",
+		youTubeEmbedDomain: "www.youtube-nocookie.com",
+		options: map[string]*configValue{
+			"ADMIN_PASSWORD": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+				Secret:            true,
+			},
+			"ADMIN_PASSWORD_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         secretFileType,
+				TargetKey:         "ADMIN_PASSWORD",
+			},
+			"ADMIN_USERNAME": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"ADMIN_USERNAME_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         secretFileType,
+				TargetKey:         "ADMIN_USERNAME",
+			},
+			"AUTH_PROXY_HEADER": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"AUTH_PROXY_USER_CREATION": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"BASE_URL": {
+				ParsedStringValue: "http://localhost",
+				RawValue:          "http://localhost",
+				ValueType:         stringType,
+			},
+			"BATCH_SIZE": {
+				ParsedIntValue: 100,
+				RawValue:       "100",
+				ValueType:      intType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"CERT_DOMAIN": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"CERT_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"CLEANUP_ARCHIVE_BATCH_SIZE": {
+				ParsedIntValue: 10000,
+				RawValue:       "10000",
+				ValueType:      intType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"CLEANUP_ARCHIVE_READ_DAYS": {
+				ParsedDuration: time.Hour * 24 * 60,
+				RawValue:       "60",
+				ValueType:      dayType,
+			},
+			"CLEANUP_ARCHIVE_UNREAD_DAYS": {
+				ParsedDuration: time.Hour * 24 * 180,
+				RawValue:       "180",
+				ValueType:      dayType,
+			},
+			"CLEANUP_FREQUENCY_HOURS": {
+				ParsedDuration: time.Hour * 24,
+				RawValue:       "24",
+				ValueType:      hourType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"CLEANUP_REMOVE_SESSIONS_DAYS": {
+				ParsedDuration: time.Hour * 24 * 30,
+				RawValue:       "30",
+				ValueType:      dayType,
+			},
+			"CREATE_ADMIN": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"DATABASE_CONNECTION_LIFETIME": {
+				ParsedDuration: time.Minute * 5,
+				RawValue:       "5",
+				ValueType:      minuteType,
+				Validator: func(rawValue string) error {
+					return validateGreaterThan(rawValue, 0)
+				},
+			},
+			"DATABASE_MAX_CONNS": {
+				ParsedIntValue: 20,
+				RawValue:       "20",
+				ValueType:      intType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"DATABASE_MIN_CONNS": {
+				ParsedIntValue: 1,
+				RawValue:       "1",
+				ValueType:      intType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 0)
+				},
+			},
+			"DATABASE_URL": {
+				ParsedStringValue: "user=postgres password=postgres dbname=miniflux2 sslmode=disable",
+				RawValue:          "user=postgres password=postgres dbname=miniflux2 sslmode=disable",
+				ValueType:         stringType,
+				Secret:            true,
+			},
+			"DATABASE_URL_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         secretFileType,
+				TargetKey:         "DATABASE_URL",
+			},
+			"DISABLE_HSTS": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"DISABLE_HTTP_SERVICE": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"DISABLE_LOCAL_AUTH": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"DISABLE_SCHEDULER_SERVICE": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"FETCH_BILIBILI_WATCH_TIME": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"FETCH_NEBULA_WATCH_TIME": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"FETCH_ODYSEE_WATCH_TIME": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"FETCH_YOUTUBE_WATCH_TIME": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"FILTER_ENTRY_MAX_AGE_DAYS": {
+				ParsedIntValue: 0,
+				RawValue:       "0",
+				ValueType:      intType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 0)
+				},
+			},
+			"FORCE_REFRESH_INTERVAL": {
+				ParsedDuration: 30 * time.Minute,
+				RawValue:       "30",
+				ValueType:      minuteType,
+				Validator: func(rawValue string) error {
+					return validateGreaterThan(rawValue, 0)
+				},
+			},
+			"HTTP_CLIENT_MAX_BODY_SIZE": {
+				ParsedInt64Value: 15,
+				RawValue:         "15",
+				ValueType:        int64Type,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"HTTP_CLIENT_PROXIES": {
+				ParsedStringList: []string{},
+				RawValue:         "",
+				ValueType:        stringListType,
+				Secret:           true,
+			},
+			"HTTP_CLIENT_PROXY": {
+				ParsedURLValue: nil,
+				RawValue:       "",
+				ValueType:      urlType,
+				Secret:         true,
+			},
+			"HTTP_CLIENT_TIMEOUT": {
+				ParsedDuration: 20 * time.Second,
+				RawValue:       "20",
+				ValueType:      secondType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"HTTP_CLIENT_USER_AGENT": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"HTTP_SERVER_TIMEOUT": {
+				ParsedDuration: 300 * time.Second,
+				RawValue:       "300",
+				ValueType:      secondType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"HTTPS": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"INVIDIOUS_INSTANCE": {
+				ParsedStringValue: "yewtu.be",
+				RawValue:          "yewtu.be",
+				ValueType:         stringType,
+			},
+			"KEY_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"LISTEN_ADDR": {
+				ParsedStringList: []string{"127.0.0.1:8080"},
+				RawValue:         "127.0.0.1:8080",
+				ValueType:        stringListType,
+			},
+			"LOG_DATE_TIME": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"LOG_FILE": {
+				ParsedStringValue: "stderr",
+				RawValue:          "stderr",
+				ValueType:         stringType,
+			},
+			"LOG_FORMAT": {
+				ParsedStringValue: "text",
+				RawValue:          "text",
+				ValueType:         stringType,
+				Validator: func(rawValue string) error {
+					return validateChoices(rawValue, []string{"text", "json"})
+				},
+			},
+			"LOG_LEVEL": {
+				ParsedStringValue: "info",
+				RawValue:          "info",
+				ValueType:         stringType,
+				Validator: func(rawValue string) error {
+					return validateChoices(rawValue, []string{"debug", "info", "warning", "error"})
+				},
+			},
+			"MAINTENANCE_MESSAGE": {
+				ParsedStringValue: "Miniflux is currently under maintenance",
+				RawValue:          "Miniflux is currently under maintenance",
+				ValueType:         stringType,
+			},
+			"MAINTENANCE_MODE": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"MEDIA_PROXY_CUSTOM_URL": {
+				RawValue:  "",
+				ValueType: urlType,
+			},
+			"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT": {
+				ParsedDuration: 120 * time.Second,
+				RawValue:       "120",
+				ValueType:      secondType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"MEDIA_PROXY_MODE": {
+				ParsedStringValue: "http-only",
+				RawValue:          "http-only",
+				ValueType:         stringType,
+				Validator: func(rawValue string) error {
+					return validateChoices(rawValue, []string{"none", "http-only", "all"})
+				},
+			},
+			"MEDIA_PROXY_PRIVATE_KEY": {
+				ValueType: bytesType,
+				Secret:    true,
+			},
+			"MEDIA_PROXY_RESOURCE_TYPES": {
+				ParsedStringList: []string{"image"},
+				RawValue:         "image",
+				ValueType:        stringListType,
+				Validator: func(rawValue string) error {
+					return validateListChoices(strings.Split(rawValue, ","), []string{"image", "video", "audio"})
+				},
+			},
+			"METRICS_ALLOWED_NETWORKS": {
+				ParsedStringList: []string{"127.0.0.1/8"},
+				RawValue:         "127.0.0.1/8",
+				ValueType:        stringListType,
+			},
+			"METRICS_COLLECTOR": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"METRICS_PASSWORD": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+				Secret:            true,
+			},
+			"METRICS_PASSWORD_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         secretFileType,
+				TargetKey:         "METRICS_PASSWORD",
+			},
+			"METRICS_REFRESH_INTERVAL": {
+				ParsedDuration: 60 * time.Second,
+				RawValue:       "60",
+				ValueType:      secondType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"METRICS_USERNAME": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"METRICS_USERNAME_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         secretFileType,
+				TargetKey:         "METRICS_USERNAME",
+			},
+			"OAUTH2_CLIENT_ID": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+				Secret:            true,
+			},
+			"OAUTH2_CLIENT_ID_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         secretFileType,
+				TargetKey:         "OAUTH2_CLIENT_ID",
+			},
+			"OAUTH2_CLIENT_SECRET": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+				Secret:            true,
+			},
+			"OAUTH2_CLIENT_SECRET_FILE": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         secretFileType,
+				TargetKey:         "OAUTH2_CLIENT_SECRET",
+			},
+			"OAUTH2_OIDC_DISCOVERY_ENDPOINT": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"OAUTH2_OIDC_PROVIDER_NAME": {
+				ParsedStringValue: "OpenID Connect",
+				RawValue:          "OpenID Connect",
+				ValueType:         stringType,
+			},
+			"OAUTH2_PROVIDER": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+				Validator: func(rawValue string) error {
+					return validateChoices(rawValue, []string{"oidc", "google"})
+				},
+			},
+			"OAUTH2_REDIRECT_URL": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+			},
+			"OAUTH2_USER_CREATION": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"POLLING_FREQUENCY": {
+				ParsedDuration: 60 * time.Minute,
+				RawValue:       "60",
+				ValueType:      minuteType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"POLLING_LIMIT_PER_HOST": {
+				ParsedIntValue: 0,
+				RawValue:       "0",
+				ValueType:      intType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 0)
+				},
+			},
+			"POLLING_PARSING_ERROR_LIMIT": {
+				ParsedIntValue: 3,
+				RawValue:       "3",
+				ValueType:      intType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 0)
+				},
+			},
+			"POLLING_SCHEDULER": {
+				ParsedStringValue: "round_robin",
+				RawValue:          "round_robin",
+				ValueType:         stringType,
+				Validator: func(rawValue string) error {
+					return validateChoices(rawValue, []string{"round_robin", "entry_frequency"})
+				},
+			},
+			"PORT": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+				Validator: func(rawValue string) error {
+					return validateRange(rawValue, 1, 65535)
+				},
+			},
+			"RUN_MIGRATIONS": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"SCHEDULER_ENTRY_FREQUENCY_FACTOR": {
+				ParsedIntValue: 1,
+				RawValue:       "1",
+				ValueType:      intType,
+			},
+			"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": {
+				ParsedDuration: 24 * time.Hour,
+				RawValue:       "1440",
+				ValueType:      minuteType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": {
+				ParsedDuration: 5 * time.Minute,
+				RawValue:       "5",
+				ValueType:      minuteType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL": {
+				ParsedDuration: 1440 * time.Minute,
+				RawValue:       "1440",
+				ValueType:      minuteType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL": {
+				ParsedDuration: 60 * time.Minute,
+				RawValue:       "60",
+				ValueType:      minuteType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"WATCHDOG": {
+				ParsedBoolValue: true,
+				RawValue:        "1",
+				ValueType:       boolType,
+			},
+			"WEBAUTHN": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
+			"WORKER_POOL_SIZE": {
+				ParsedIntValue: 16,
+				RawValue:       "16",
+				ValueType:      intType,
+				Validator: func(rawValue string) error {
+					return validateGreaterOrEqualThan(rawValue, 1)
+				},
+			},
+			"YOUTUBE_API_KEY": {
+				ParsedStringValue: "",
+				RawValue:          "",
+				ValueType:         stringType,
+				Secret:            true,
+			},
+			"YOUTUBE_EMBED_URL_OVERRIDE": {
+				ParsedStringValue: "https://www.youtube-nocookie.com/embed/",
+				RawValue:          "https://www.youtube-nocookie.com/embed/",
+				ValueType:         stringType,
+			},
+		},
 	}
 }
 
-func (o *options) LogFile() string {
-	return o.logFile
+func (c *configOptions) AdminPassword() string {
+	return c.options["ADMIN_PASSWORD"].ParsedStringValue
 }
 
-// LogDateTime returns true if the date/time should be displayed in log messages.
-func (o *options) LogDateTime() bool {
-	return o.logDateTime
+func (c *configOptions) AdminUsername() string {
+	return c.options["ADMIN_USERNAME"].ParsedStringValue
 }
 
-// LogFormat returns the log format.
-func (o *options) LogFormat() string {
-	return o.logFormat
+func (c *configOptions) AuthProxyHeader() string {
+	return c.options["AUTH_PROXY_HEADER"].ParsedStringValue
 }
 
-// LogLevel returns the log level.
-func (o *options) LogLevel() string {
-	return o.logLevel
+func (c *configOptions) AuthProxyUserCreation() bool {
+	return c.options["AUTH_PROXY_USER_CREATION"].ParsedBoolValue
 }
 
-// SetLogLevel sets the log level.
-func (o *options) SetLogLevel(level string) {
-	o.logLevel = level
+func (c *configOptions) BasePath() string {
+	return c.basePath
 }
 
-// HasMaintenanceMode returns true if maintenance mode is enabled.
-func (o *options) HasMaintenanceMode() bool {
-	return o.maintenanceMode
+func (c *configOptions) BaseURL() string {
+	return c.options["BASE_URL"].ParsedStringValue
 }
 
-// MaintenanceMessage returns maintenance message.
-func (o *options) MaintenanceMessage() string {
-	return o.maintenanceMessage
+func (c *configOptions) RootURL() string {
+	return c.rootURL
 }
 
-// BaseURL returns the application base URL with path.
-func (o *options) BaseURL() string {
-	return o.baseURL
+func (c *configOptions) BatchSize() int {
+	return c.options["BATCH_SIZE"].ParsedIntValue
 }
 
-// RootURL returns the base URL without path.
-func (o *options) RootURL() string {
-	return o.rootURL
+func (c *configOptions) CertDomain() string {
+	return c.options["CERT_DOMAIN"].ParsedStringValue
 }
 
-// BasePath returns the application base path according to the base URL.
-func (o *options) BasePath() string {
-	return o.basePath
+func (c *configOptions) CertFile() string {
+	return c.options["CERT_FILE"].ParsedStringValue
 }
 
-// IsDefaultDatabaseURL returns true if the default database URL is used.
-func (o *options) IsDefaultDatabaseURL() bool {
-	return o.databaseURL == defaultDatabaseURL
+func (c *configOptions) CleanupArchiveBatchSize() int {
+	return c.options["CLEANUP_ARCHIVE_BATCH_SIZE"].ParsedIntValue
 }
 
-// DatabaseURL returns the database URL.
-func (o *options) DatabaseURL() string {
-	return o.databaseURL
+func (c *configOptions) CleanupArchiveReadInterval() time.Duration {
+	return c.options["CLEANUP_ARCHIVE_READ_DAYS"].ParsedDuration
 }
 
-// DatabaseMaxConns returns the maximum number of database connections.
-func (o *options) DatabaseMaxConns() int {
-	return o.databaseMaxConns
+func (c *configOptions) CleanupArchiveUnreadInterval() time.Duration {
+	return c.options["CLEANUP_ARCHIVE_UNREAD_DAYS"].ParsedDuration
 }
 
-// DatabaseMinConns returns the minimum number of database connections.
-func (o *options) DatabaseMinConns() int {
-	return o.databaseMinConns
+func (c *configOptions) CleanupFrequency() time.Duration {
+	return c.options["CLEANUP_FREQUENCY_HOURS"].ParsedDuration
 }
 
-// DatabaseConnectionLifetime returns the maximum amount of time a connection may be reused.
-func (o *options) DatabaseConnectionLifetime() time.Duration {
-	return time.Duration(o.databaseConnectionLifetime) * time.Minute
+func (c *configOptions) CleanupRemoveSessionsInterval() time.Duration {
+	return c.options["CLEANUP_REMOVE_SESSIONS_DAYS"].ParsedDuration
 }
 
-// ListenAddr returns the listen address for the HTTP server.
-func (o *options) ListenAddr() []string {
-	return o.listenAddr
+func (c *configOptions) CreateAdmin() bool {
+	return c.options["CREATE_ADMIN"].ParsedBoolValue
 }
 
-// CertFile returns the SSL certificate filename if any.
-func (o *options) CertFile() string {
-	return o.certFile
+func (c *configOptions) DatabaseConnectionLifetime() time.Duration {
+	return c.options["DATABASE_CONNECTION_LIFETIME"].ParsedDuration
 }
 
-// CertKeyFile returns the private key filename for custom SSL certificate.
-func (o *options) CertKeyFile() string {
-	return o.certKeyFile
+func (c *configOptions) DatabaseMaxConns() int {
+	return c.options["DATABASE_MAX_CONNS"].ParsedIntValue
 }
 
-// CertDomain returns the domain to use for Let's Encrypt certificate.
-func (o *options) CertDomain() string {
-	return o.certDomain
+func (c *configOptions) DatabaseMinConns() int {
+	return c.options["DATABASE_MIN_CONNS"].ParsedIntValue
 }
 
-// CleanupFrequencyHours returns the interval for cleanup jobs.
-func (o *options) CleanupFrequency() time.Duration {
-	return o.cleanupFrequencyInterval
+func (c *configOptions) DatabaseURL() string {
+	return c.options["DATABASE_URL"].ParsedStringValue
 }
 
-// CleanupArchiveReadDays returns the interval after which marking read items as removed.
-func (o *options) CleanupArchiveReadInterval() time.Duration {
-	return o.cleanupArchiveReadInterval
+func (c *configOptions) DisableHSTS() bool {
+	return c.options["DISABLE_HSTS"].ParsedBoolValue
 }
 
-// CleanupArchiveUnreadDays returns the interval after which marking unread items as removed.
-func (o *options) CleanupArchiveUnreadInterval() time.Duration {
-	return o.cleanupArchiveUnreadInterval
+func (c *configOptions) DisableHTTPService() bool {
+	return c.options["DISABLE_HTTP_SERVICE"].ParsedBoolValue
 }
 
-// CleanupArchiveBatchSize returns the number of entries to archive for each interval.
-func (o *options) CleanupArchiveBatchSize() int {
-	return o.cleanupArchiveBatchSize
+func (c *configOptions) DisableLocalAuth() bool {
+	return c.options["DISABLE_LOCAL_AUTH"].ParsedBoolValue
 }
 
-// CleanupRemoveSessionsDays returns the interval after which to remove sessions.
-func (o *options) CleanupRemoveSessionsInterval() time.Duration {
-	return o.cleanupRemoveSessionsInterval
+func (c *configOptions) DisableSchedulerService() bool {
+	return c.options["DISABLE_SCHEDULER_SERVICE"].ParsedBoolValue
 }
 
-// WorkerPoolSize returns the number of background worker.
-func (o *options) WorkerPoolSize() int {
-	return o.workerPoolSize
+func (c *configOptions) FetchBilibiliWatchTime() bool {
+	return c.options["FETCH_BILIBILI_WATCH_TIME"].ParsedBoolValue
 }
 
-// ForceRefreshInterval returns the force refresh interval
-func (o *options) ForceRefreshInterval() time.Duration {
-	return o.forceRefreshInterval
+func (c *configOptions) FetchNebulaWatchTime() bool {
+	return c.options["FETCH_NEBULA_WATCH_TIME"].ParsedBoolValue
 }
 
-// BatchSize returns the number of feeds to send for background processing.
-func (o *options) BatchSize() int {
-	return o.batchSize
+func (c *configOptions) FetchOdyseeWatchTime() bool {
+	return c.options["FETCH_ODYSEE_WATCH_TIME"].ParsedBoolValue
 }
 
-// PollingFrequency returns the interval to refresh feeds in the background.
-func (o *options) PollingFrequency() time.Duration {
-	return o.pollingFrequency
+func (c *configOptions) FetchYouTubeWatchTime() bool {
+	return c.options["FETCH_YOUTUBE_WATCH_TIME"].ParsedBoolValue
 }
 
-// PollingLimitPerHost returns the limit of concurrent requests per host.
-// Set to zero to disable.
-func (o *options) PollingLimitPerHost() int {
-	return o.pollingLimitPerHost
+func (c *configOptions) FilterEntryMaxAgeDays() int {
+	return c.options["FILTER_ENTRY_MAX_AGE_DAYS"].ParsedIntValue
 }
 
-// PollingParsingErrorLimit returns the limit of errors when to stop polling.
-func (o *options) PollingParsingErrorLimit() int {
-	return o.pollingParsingErrorLimit
+func (c *configOptions) ForceRefreshInterval() time.Duration {
+	return c.options["FORCE_REFRESH_INTERVAL"].ParsedDuration
 }
 
-// PollingScheduler returns the scheduler used for polling feeds.
-func (o *options) PollingScheduler() string {
-	return o.pollingScheduler
+func (c *configOptions) HasHTTPClientProxiesConfigured() bool {
+	return len(c.options["HTTP_CLIENT_PROXIES"].ParsedStringList) > 0
 }
 
-// SchedulerEntryFrequencyMaxInterval returns the maximum interval for the entry frequency scheduler.
-func (o *options) SchedulerEntryFrequencyMaxInterval() time.Duration {
-	return o.schedulerEntryFrequencyMaxInterval
+func (c *configOptions) HasHTTPService() bool {
+	return !c.options["DISABLE_HTTP_SERVICE"].ParsedBoolValue
 }
 
-// SchedulerEntryFrequencyMinInterval returns the minimum interval for the entry frequency scheduler.
-func (o *options) SchedulerEntryFrequencyMinInterval() time.Duration {
-	return o.schedulerEntryFrequencyMinInterval
+func (c *configOptions) HasHSTS() bool {
+	return !c.options["DISABLE_HSTS"].ParsedBoolValue
 }
 
-// SchedulerEntryFrequencyFactor returns the factor for the entry frequency scheduler.
-func (o *options) SchedulerEntryFrequencyFactor() int {
-	return o.schedulerEntryFrequencyFactor
+func (c *configOptions) HasHTTPClientProxyURLConfigured() bool {
+	return c.options["HTTP_CLIENT_PROXY"].ParsedURLValue != nil
 }
 
-func (o *options) SchedulerRoundRobinMinInterval() time.Duration {
-	return o.schedulerRoundRobinMinInterval
+func (c *configOptions) HasMaintenanceMode() bool {
+	return c.options["MAINTENANCE_MODE"].ParsedBoolValue
 }
 
-func (o *options) SchedulerRoundRobinMaxInterval() time.Duration {
-	return o.schedulerRoundRobinMaxInterval
+func (c *configOptions) HasMetricsCollector() bool {
+	return c.options["METRICS_COLLECTOR"].ParsedBoolValue
 }
 
-// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
-func (o *options) IsOAuth2UserCreationAllowed() bool {
-	return o.oauth2UserCreationAllowed
+func (c *configOptions) HasSchedulerService() bool {
+	return !c.options["DISABLE_SCHEDULER_SERVICE"].ParsedBoolValue
 }
 
-// OAuth2ClientID returns the OAuth2 Client ID.
-func (o *options) OAuth2ClientID() string {
-	return o.oauth2ClientID
+func (c *configOptions) HasWatchdog() bool {
+	return c.options["WATCHDOG"].ParsedBoolValue
 }
 
-// OAuth2ClientSecret returns the OAuth2 client secret.
-func (o *options) OAuth2ClientSecret() string {
-	return o.oauth2ClientSecret
+func (c *configOptions) HTTPClientMaxBodySize() int64 {
+	return c.options["HTTP_CLIENT_MAX_BODY_SIZE"].ParsedInt64Value * 1024 * 1024
 }
 
-// OAuth2RedirectURL returns the OAuth2 redirect URL.
-func (o *options) OAuth2RedirectURL() string {
-	return o.oauth2RedirectURL
+func (c *configOptions) HTTPClientProxies() []string {
+	return c.options["HTTP_CLIENT_PROXIES"].ParsedStringList
 }
 
-// OIDCDiscoveryEndpoint returns the OAuth2 OIDC discovery endpoint.
-func (o *options) OIDCDiscoveryEndpoint() string {
-	return o.oidcDiscoveryEndpoint
+func (c *configOptions) HTTPClientProxyURL() *url.URL {
+	return c.options["HTTP_CLIENT_PROXY"].ParsedURLValue
 }
 
-// OIDCProviderName returns the OAuth2 OIDC provider's display name
-func (o *options) OIDCProviderName() string {
-	return o.oidcProviderName
+func (c *configOptions) HTTPClientTimeout() time.Duration {
+	return c.options["HTTP_CLIENT_TIMEOUT"].ParsedDuration
 }
 
-// OAuth2Provider returns the name of the OAuth2 provider configured.
-func (o *options) OAuth2Provider() string {
-	return o.oauth2Provider
+func (c *configOptions) HTTPClientUserAgent() string {
+	if c.options["HTTP_CLIENT_USER_AGENT"].ParsedStringValue != "" {
+		return c.options["HTTP_CLIENT_USER_AGENT"].ParsedStringValue
+	}
+	return defaultHTTPClientUserAgent
 }
 
-// DisableLocalAUth returns true if the local user database should not be used to authenticate users
-func (o *options) DisableLocalAuth() bool {
-	return o.disableLocalAuth
+func (c *configOptions) HTTPServerTimeout() time.Duration {
+	return c.options["HTTP_SERVER_TIMEOUT"].ParsedDuration
 }
 
-// HasHSTS returns true if HTTP Strict Transport Security is enabled.
-func (o *options) HasHSTS() bool {
-	return o.hsts
+func (c *configOptions) HTTPS() bool {
+	return c.options["HTTPS"].ParsedBoolValue
 }
 
-// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
-func (o *options) RunMigrations() bool {
-	return o.runMigrations
+func (c *configOptions) InvidiousInstance() string {
+	return c.options["INVIDIOUS_INSTANCE"].ParsedStringValue
 }
 
-// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
-func (o *options) CreateAdmin() bool {
-	return o.createAdmin
+func (c *configOptions) IsAuthProxyUserCreationAllowed() bool {
+	return c.options["AUTH_PROXY_USER_CREATION"].ParsedBoolValue
 }
 
-// AdminUsername returns the admin username if defined.
-func (o *options) AdminUsername() string {
-	return o.adminUsername
+func (c *configOptions) IsDefaultDatabaseURL() bool {
+	return c.options["DATABASE_URL"].RawValue == "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
 }
 
-// AdminPassword returns the admin password if defined.
-func (o *options) AdminPassword() string {
-	return o.adminPassword
+func (c *configOptions) IsOAuth2UserCreationAllowed() bool {
+	return c.options["OAUTH2_USER_CREATION"].ParsedBoolValue
 }
 
-// FetchYouTubeWatchTime returns true if the YouTube video duration
-// should be fetched and used as a reading time.
-func (o *options) FetchYouTubeWatchTime() bool {
-	return o.fetchYouTubeWatchTime
+func (c *configOptions) CertKeyFile() string {
+	return c.options["KEY_FILE"].ParsedStringValue
 }
 
-// YouTubeApiKey returns the YouTube API key if defined.
-func (o *options) YouTubeApiKey() string {
-	return o.youTubeApiKey
+func (c *configOptions) ListenAddr() []string {
+	return c.options["LISTEN_ADDR"].ParsedStringList
 }
 
-// YouTubeEmbedUrlOverride returns the YouTube embed URL override if defined.
-func (o *options) YouTubeEmbedUrlOverride() string {
-	return o.youTubeEmbedUrlOverride
+func (c *configOptions) LogFile() string {
+	return c.options["LOG_FILE"].ParsedStringValue
 }
 
-// YouTubeEmbedDomain returns the domain used for YouTube embeds.
-func (o *options) YouTubeEmbedDomain() string {
-	if o.youTubeEmbedDomain != "" {
-		return o.youTubeEmbedDomain
-	}
-	return "www.youtube-nocookie.com"
+func (c *configOptions) LogDateTime() bool {
+	return c.options["LOG_DATE_TIME"].ParsedBoolValue
 }
 
-// FetchNebulaWatchTime returns true if the Nebula video duration
-// should be fetched and used as a reading time.
-func (o *options) FetchNebulaWatchTime() bool {
-	return o.fetchNebulaWatchTime
+func (c *configOptions) LogFormat() string {
+	return c.options["LOG_FORMAT"].ParsedStringValue
 }
 
-// FetchOdyseeWatchTime returns true if the Odysee video duration
-// should be fetched and used as a reading time.
-func (o *options) FetchOdyseeWatchTime() bool {
-	return o.fetchOdyseeWatchTime
+func (c *configOptions) LogLevel() string {
+	return c.options["LOG_LEVEL"].ParsedStringValue
 }
 
-// FetchBilibiliWatchTime returns true if the Bilibili video duration
-// should be fetched and used as a reading time.
-func (o *options) FetchBilibiliWatchTime() bool {
-	return o.fetchBilibiliWatchTime
+func (c *configOptions) MaintenanceMessage() string {
+	return c.options["MAINTENANCE_MESSAGE"].ParsedStringValue
 }
 
-// MediaProxyMode returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
-func (o *options) MediaProxyMode() string {
-	return o.mediaProxyMode
+func (c *configOptions) MaintenanceMode() bool {
+	return c.options["MAINTENANCE_MODE"].ParsedBoolValue
 }
 
-// MediaProxyResourceTypes returns a slice of resource types to proxy.
-func (o *options) MediaProxyResourceTypes() []string {
-	return o.mediaProxyResourceTypes
+func (c *configOptions) MediaCustomProxyURL() *url.URL {
+	return c.options["MEDIA_PROXY_CUSTOM_URL"].ParsedURLValue
 }
 
-// MediaCustomProxyURL returns the custom proxy URL for medias.
-func (o *options) MediaCustomProxyURL() *url.URL {
-	return o.mediaProxyCustomURL
+func (c *configOptions) MediaProxyHTTPClientTimeout() time.Duration {
+	return c.options["MEDIA_PROXY_HTTP_CLIENT_TIMEOUT"].ParsedDuration
 }
 
-// MediaProxyHTTPClientTimeout returns the time limit before the proxy HTTP client cancel the request.
-func (o *options) MediaProxyHTTPClientTimeout() time.Duration {
-	return o.mediaProxyHTTPClientTimeout
+func (c *configOptions) MediaProxyMode() string {
+	return c.options["MEDIA_PROXY_MODE"].ParsedStringValue
 }
 
-// MediaProxyPrivateKey returns the private key used by the media proxy.
-func (o *options) MediaProxyPrivateKey() []byte {
-	return o.mediaProxyPrivateKey
+func (c *configOptions) MediaProxyPrivateKey() []byte {
+	return c.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue
 }
 
-// HasHTTPService returns true if the HTTP service is enabled.
-func (o *options) HasHTTPService() bool {
-	return o.httpService
+func (c *configOptions) MediaProxyResourceTypes() []string {
+	return c.options["MEDIA_PROXY_RESOURCE_TYPES"].ParsedStringList
 }
 
-// HasSchedulerService returns true if the scheduler service is enabled.
-func (o *options) HasSchedulerService() bool {
-	return o.schedulerService
+func (c *configOptions) MetricsAllowedNetworks() []string {
+	return c.options["METRICS_ALLOWED_NETWORKS"].ParsedStringList
 }
 
-// HTTPClientTimeout returns the time limit in seconds before the HTTP client cancel the request.
-func (o *options) HTTPClientTimeout() time.Duration {
-	return o.httpClientTimeout
+func (c *configOptions) MetricsCollector() bool {
+	return c.options["METRICS_COLLECTOR"].ParsedBoolValue
 }
 
-// HTTPClientMaxBodySize returns the number of bytes allowed for the HTTP client to transfer.
-func (o *options) HTTPClientMaxBodySize() int64 {
-	return o.httpClientMaxBodySize
+func (c *configOptions) MetricsPassword() string {
+	return c.options["METRICS_PASSWORD"].ParsedStringValue
 }
 
-// HTTPClientProxyURL returns the client HTTP proxy URL if configured.
-func (o *options) HTTPClientProxyURL() *url.URL {
-	return o.httpClientProxyURL
+func (c *configOptions) MetricsRefreshInterval() time.Duration {
+	return c.options["METRICS_REFRESH_INTERVAL"].ParsedDuration
 }
 
-// HasHTTPClientProxyURLConfigured returns true if the client HTTP proxy URL if configured.
-func (o *options) HasHTTPClientProxyURLConfigured() bool {
-	return o.httpClientProxyURL != nil
+func (c *configOptions) MetricsUsername() string {
+	return c.options["METRICS_USERNAME"].ParsedStringValue
 }
 
-// HTTPClientProxies returns the list of proxies.
-func (o *options) HTTPClientProxies() []string {
-	return o.httpClientProxies
+func (c *configOptions) OAuth2ClientID() string {
+	return c.options["OAUTH2_CLIENT_ID"].ParsedStringValue
 }
 
-// HTTPClientProxiesString returns true if the list of rotating proxies are configured.
-func (o *options) HasHTTPClientProxiesConfigured() bool {
-	return len(o.httpClientProxies) > 0
+func (c *configOptions) OAuth2ClientSecret() string {
+	return c.options["OAUTH2_CLIENT_SECRET"].ParsedStringValue
 }
 
-// HTTPServerTimeout returns the time limit before the HTTP server cancel the request.
-func (o *options) HTTPServerTimeout() time.Duration {
-	return o.httpServerTimeout
+func (c *configOptions) OAuth2OIDCDiscoveryEndpoint() string {
+	return c.options["OAUTH2_OIDC_DISCOVERY_ENDPOINT"].ParsedStringValue
 }
 
-// AuthProxyHeader returns an HTTP header name that contains username for
-// authentication using auth proxy.
-func (o *options) AuthProxyHeader() string {
-	return o.authProxyHeader
+func (c *configOptions) OAuth2OIDCProviderName() string {
+	return c.options["OAUTH2_OIDC_PROVIDER_NAME"].ParsedStringValue
 }
 
-// IsAuthProxyUserCreationAllowed returns true if user creation is allowed for
-// users authenticated using auth proxy.
-func (o *options) IsAuthProxyUserCreationAllowed() bool {
-	return o.authProxyUserCreation
+func (c *configOptions) OAuth2Provider() string {
+	return c.options["OAUTH2_PROVIDER"].ParsedStringValue
 }
 
-// HasMetricsCollector returns true if metrics collection is enabled.
-func (o *options) HasMetricsCollector() bool {
-	return o.metricsCollector
+func (c *configOptions) OAuth2RedirectURL() string {
+	return c.options["OAUTH2_REDIRECT_URL"].ParsedStringValue
 }
 
-// MetricsRefreshInterval returns the refresh interval.
-func (o *options) MetricsRefreshInterval() time.Duration {
-	return o.metricsRefreshInterval
+func (c *configOptions) OAuth2UserCreation() bool {
+	return c.options["OAUTH2_USER_CREATION"].ParsedBoolValue
 }
 
-// MetricsAllowedNetworks returns the list of networks allowed to connect to the metrics endpoint.
-func (o *options) MetricsAllowedNetworks() []string {
-	return o.metricsAllowedNetworks
+func (c *configOptions) PollingFrequency() time.Duration {
+	return c.options["POLLING_FREQUENCY"].ParsedDuration
 }
 
-func (o *options) MetricsUsername() string {
-	return o.metricsUsername
+func (c *configOptions) PollingLimitPerHost() int {
+	return c.options["POLLING_LIMIT_PER_HOST"].ParsedIntValue
 }
 
-func (o *options) MetricsPassword() string {
-	return o.metricsPassword
+func (c *configOptions) PollingParsingErrorLimit() int {
+	return c.options["POLLING_PARSING_ERROR_LIMIT"].ParsedIntValue
 }
 
-// HTTPClientUserAgent returns the global User-Agent header for miniflux.
-func (o *options) HTTPClientUserAgent() string {
-	return o.httpClientUserAgent
+func (c *configOptions) PollingScheduler() string {
+	return c.options["POLLING_SCHEDULER"].ParsedStringValue
 }
 
-// HasWatchdog returns true if the systemd watchdog is enabled.
-func (o *options) HasWatchdog() bool {
-	return o.watchdog
+func (c *configOptions) Port() string {
+	return c.options["PORT"].ParsedStringValue
 }
 
-// InvidiousInstance returns the invidious instance used by miniflux
-func (o *options) InvidiousInstance() string {
-	return o.invidiousInstance
+func (c *configOptions) RunMigrations() bool {
+	return c.options["RUN_MIGRATIONS"].ParsedBoolValue
 }
 
-// WebAuthn returns true if WebAuthn logins are supported
-func (o *options) WebAuthn() bool {
-	return o.webAuthn
+func (c *configOptions) SetLogLevel(level string) {
+	c.options["LOG_LEVEL"].ParsedStringValue = level
+	c.options["LOG_LEVEL"].RawValue = level
 }
 
-// FilterEntryMaxAgeDays returns the number of days after which entries should be retained.
-func (o *options) FilterEntryMaxAgeDays() int {
-	return o.filterEntryMaxAgeDays
+func (c *configOptions) SetHTTPSValue(value bool) {
+	c.options["HTTPS"].ParsedBoolValue = value
+	if value {
+		c.options["HTTPS"].RawValue = "1"
+	} else {
+		c.options["HTTPS"].RawValue = "0"
+	}
 }
 
-// SortedOptions returns options as a list of key value pairs, sorted by keys.
-func (o *options) SortedOptions(redactSecret bool) []*option {
-	var clientProxyURLRedacted string
-	if o.httpClientProxyURL != nil {
-		if redactSecret {
-			clientProxyURLRedacted = o.httpClientProxyURL.Redacted()
-		} else {
-			clientProxyURLRedacted = o.httpClientProxyURL.String()
-		}
-	}
+func (c *configOptions) SchedulerEntryFrequencyFactor() int {
+	return c.options["SCHEDULER_ENTRY_FREQUENCY_FACTOR"].ParsedIntValue
+}
 
-	var clientProxyURLsRedacted string
-	if len(o.httpClientProxies) > 0 {
-		if redactSecret {
-			var proxyURLs []string
-			for range o.httpClientProxies {
-				proxyURLs = append(proxyURLs, "<redacted>")
-			}
-			clientProxyURLsRedacted = strings.Join(proxyURLs, ",")
-		} else {
-			clientProxyURLsRedacted = strings.Join(o.httpClientProxies, ",")
-		}
-	}
+func (c *configOptions) SchedulerEntryFrequencyMaxInterval() time.Duration {
+	return c.options["SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL"].ParsedDuration
+}
 
-	var mediaProxyPrivateKeyValue string
-	if len(o.mediaProxyPrivateKey) > 0 {
-		mediaProxyPrivateKeyValue = "<binary-data>"
-	}
+func (c *configOptions) SchedulerEntryFrequencyMinInterval() time.Duration {
+	return c.options["SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL"].ParsedDuration
+}
 
-	var keyValues = map[string]any{
-		"ADMIN_PASSWORD":                         redactSecretValue(o.adminPassword, redactSecret),
-		"ADMIN_USERNAME":                         o.adminUsername,
-		"AUTH_PROXY_HEADER":                      o.authProxyHeader,
-		"AUTH_PROXY_USER_CREATION":               o.authProxyUserCreation,
-		"BASE_PATH":                              o.basePath,
-		"BASE_URL":                               o.baseURL,
-		"BATCH_SIZE":                             o.batchSize,
-		"CERT_DOMAIN":                            o.certDomain,
-		"CERT_FILE":                              o.certFile,
-		"CLEANUP_FREQUENCY_HOURS":                int(o.cleanupFrequencyInterval.Hours()),
-		"CLEANUP_ARCHIVE_BATCH_SIZE":             o.cleanupArchiveBatchSize,
-		"CLEANUP_ARCHIVE_READ_DAYS":              int(o.cleanupArchiveReadInterval.Hours() / 24),
-		"CLEANUP_ARCHIVE_UNREAD_DAYS":            int(o.cleanupArchiveUnreadInterval.Hours() / 24),
-		"CLEANUP_REMOVE_SESSIONS_DAYS":           int(o.cleanupRemoveSessionsInterval.Hours() / 24),
-		"CREATE_ADMIN":                           o.createAdmin,
-		"DATABASE_CONNECTION_LIFETIME":           o.databaseConnectionLifetime,
-		"DATABASE_MAX_CONNS":                     o.databaseMaxConns,
-		"DATABASE_MIN_CONNS":                     o.databaseMinConns,
-		"DATABASE_URL":                           redactSecretValue(o.databaseURL, redactSecret),
-		"DISABLE_HSTS":                           !o.hsts,
-		"DISABLE_HTTP_SERVICE":                   !o.httpService,
-		"DISABLE_SCHEDULER_SERVICE":              !o.schedulerService,
-		"FILTER_ENTRY_MAX_AGE_DAYS":              o.filterEntryMaxAgeDays,
-		"FETCH_YOUTUBE_WATCH_TIME":               o.fetchYouTubeWatchTime,
-		"FETCH_NEBULA_WATCH_TIME":                o.fetchNebulaWatchTime,
-		"FETCH_ODYSEE_WATCH_TIME":                o.fetchOdyseeWatchTime,
-		"FETCH_BILIBILI_WATCH_TIME":              o.fetchBilibiliWatchTime,
-		"HTTPS":                                  o.HTTPS,
-		"HTTP_CLIENT_MAX_BODY_SIZE":              o.httpClientMaxBodySize,
-		"HTTP_CLIENT_PROXIES":                    clientProxyURLsRedacted,
-		"HTTP_CLIENT_PROXY":                      clientProxyURLRedacted,
-		"HTTP_CLIENT_TIMEOUT":                    int(o.httpClientTimeout.Seconds()),
-		"HTTP_CLIENT_USER_AGENT":                 o.httpClientUserAgent,
-		"HTTP_SERVER_TIMEOUT":                    int(o.httpServerTimeout.Seconds()),
-		"HTTP_SERVICE":                           o.httpService,
-		"INVIDIOUS_INSTANCE":                     o.invidiousInstance,
-		"KEY_FILE":                               o.certKeyFile,
-		"LISTEN_ADDR":                            strings.Join(o.listenAddr, ","),
-		"LOG_FILE":                               o.logFile,
-		"LOG_DATE_TIME":                          o.logDateTime,
-		"LOG_FORMAT":                             o.logFormat,
-		"LOG_LEVEL":                              o.logLevel,
-		"MAINTENANCE_MESSAGE":                    o.maintenanceMessage,
-		"MAINTENANCE_MODE":                       o.maintenanceMode,
-		"METRICS_ALLOWED_NETWORKS":               strings.Join(o.metricsAllowedNetworks, ","),
-		"METRICS_COLLECTOR":                      o.metricsCollector,
-		"METRICS_PASSWORD":                       redactSecretValue(o.metricsPassword, redactSecret),
-		"METRICS_REFRESH_INTERVAL":               int(o.metricsRefreshInterval.Seconds()),
-		"METRICS_USERNAME":                       o.metricsUsername,
-		"OAUTH2_CLIENT_ID":                       o.oauth2ClientID,
-		"OAUTH2_CLIENT_SECRET":                   redactSecretValue(o.oauth2ClientSecret, redactSecret),
-		"OAUTH2_OIDC_DISCOVERY_ENDPOINT":         o.oidcDiscoveryEndpoint,
-		"OAUTH2_OIDC_PROVIDER_NAME":              o.oidcProviderName,
-		"OAUTH2_PROVIDER":                        o.oauth2Provider,
-		"OAUTH2_REDIRECT_URL":                    o.oauth2RedirectURL,
-		"OAUTH2_USER_CREATION":                   o.oauth2UserCreationAllowed,
-		"DISABLE_LOCAL_AUTH":                     o.disableLocalAuth,
-		"FORCE_REFRESH_INTERVAL":                 int(o.forceRefreshInterval.Minutes()),
-		"POLLING_FREQUENCY":                      int(o.pollingFrequency.Minutes()),
-		"POLLING_LIMIT_PER_HOST":                 o.pollingLimitPerHost,
-		"POLLING_PARSING_ERROR_LIMIT":            o.pollingParsingErrorLimit,
-		"POLLING_SCHEDULER":                      o.pollingScheduler,
-		"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":        int(o.mediaProxyHTTPClientTimeout.Seconds()),
-		"MEDIA_PROXY_RESOURCE_TYPES":             o.mediaProxyResourceTypes,
-		"MEDIA_PROXY_MODE":                       o.mediaProxyMode,
-		"MEDIA_PROXY_PRIVATE_KEY":                mediaProxyPrivateKeyValue,
-		"MEDIA_PROXY_CUSTOM_URL":                 o.mediaProxyCustomURL,
-		"ROOT_URL":                               o.rootURL,
-		"RUN_MIGRATIONS":                         o.runMigrations,
-		"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": int(o.schedulerEntryFrequencyMaxInterval.Minutes()),
-		"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL": int(o.schedulerEntryFrequencyMinInterval.Minutes()),
-		"SCHEDULER_ENTRY_FREQUENCY_FACTOR":       o.schedulerEntryFrequencyFactor,
-		"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":     int(o.schedulerRoundRobinMinInterval.Minutes()),
-		"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":     int(o.schedulerRoundRobinMaxInterval.Minutes()),
-		"SCHEDULER_SERVICE":                      o.schedulerService,
-		"WATCHDOG":                               o.watchdog,
-		"WORKER_POOL_SIZE":                       o.workerPoolSize,
-		"YOUTUBE_API_KEY":                        redactSecretValue(o.youTubeApiKey, redactSecret),
-		"YOUTUBE_EMBED_URL_OVERRIDE":             o.youTubeEmbedUrlOverride,
-		"WEBAUTHN":                               o.webAuthn,
-	}
+func (c *configOptions) SchedulerRoundRobinMaxInterval() time.Duration {
+	return c.options["SCHEDULER_ROUND_ROBIN_MAX_INTERVAL"].ParsedDuration
+}
+
+func (c *configOptions) SchedulerRoundRobinMinInterval() time.Duration {
+	return c.options["SCHEDULER_ROUND_ROBIN_MIN_INTERVAL"].ParsedDuration
+}
+
+func (c *configOptions) Watchdog() bool {
+	return c.options["WATCHDOG"].ParsedBoolValue
+}
 
-	sortedKeys := slices.Sorted(maps.Keys(keyValues))
-	var sortedOptions = make([]*option, 0, len(sortedKeys))
+func (c *configOptions) WebAuthn() bool {
+	return c.options["WEBAUTHN"].ParsedBoolValue
+}
+
+func (c *configOptions) WorkerPoolSize() int {
+	return c.options["WORKER_POOL_SIZE"].ParsedIntValue
+}
+
+func (c *configOptions) YouTubeAPIKey() string {
+	return c.options["YOUTUBE_API_KEY"].ParsedStringValue
+}
+
+func (c *configOptions) YouTubeEmbedUrlOverride() string {
+	return c.options["YOUTUBE_EMBED_URL_OVERRIDE"].ParsedStringValue
+}
+
+func (c *configOptions) YouTubeEmbedDomain() string {
+	return c.youTubeEmbedDomain
+}
+
+func (c *configOptions) ConfigMap(redactSecret bool) []*optionPair {
+	sortedKeys := slices.Sorted(maps.Keys(c.options))
+	sortedOptions := make([]*optionPair, 0, len(sortedKeys))
 	for _, key := range sortedKeys {
-		sortedOptions = append(sortedOptions, &option{Key: key, Value: keyValues[key]})
+		value := c.options[key]
+		displayValue := value.RawValue
+		if redactSecret && value.Secret && displayValue != "" {
+			displayValue = "<redacted>"
+		}
+		sortedOptions = append(sortedOptions, &optionPair{Key: key, Value: displayValue})
 	}
 	return sortedOptions
 }
 
-func (o *options) String() string {
+func (c *configOptions) String() string {
 	var builder strings.Builder
 
-	for _, option := range o.SortedOptions(false) {
+	for _, option := range c.ConfigMap(false) {
 		fmt.Fprintf(&builder, "%s=%v\n", option.Key, option.Value)
 	}
 
 	return builder.String()
 }
-
-func redactSecretValue(value string, redactSecret bool) string {
-	if redactSecret && value != "" {
-		return "<secret>"
-	}
-	return value
-}

+ 1688 - 0
internal/config/options_parsing_test.go

@@ -0,0 +1,1688 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package config // import "miniflux.app/v2/internal/config"
+
+import "testing"
+
+func TestBaseURLOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.BaseURL() != "http://localhost" {
+		t.Fatalf("Expected BASE_URL to be 'http://localhost' by default")
+	}
+
+	if configParser.options.RootURL() != "http://localhost" {
+		t.Fatalf("Expected ROOT_URL to be 'http://localhost' by default")
+	}
+
+	if configParser.options.BasePath() != "" {
+		t.Fatalf("Expected BASE_PATH to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"BASE_URL=https://example.com/app"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.BaseURL() != "https://example.com/app" {
+		t.Fatalf("Expected BASE_URL to be 'https://example.com/app', got '%s'", configParser.options.BaseURL())
+	}
+
+	if configParser.options.RootURL() != "https://example.com" {
+		t.Fatalf("Expected ROOT_URL to be 'https://example.com', got '%s'", configParser.options.RootURL())
+	}
+
+	if configParser.options.BasePath() != "/app" {
+		t.Fatalf("Expected BASE_PATH to be '/app', got '%s'", configParser.options.BasePath())
+	}
+
+	if err := configParser.parseLines([]string{"BASE_URL=https://example.com/app/"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.BaseURL() != "https://example.com/app" {
+		t.Fatalf("Expected BASE_URL to be 'https://example.com/app', got '%s'", configParser.options.BaseURL())
+	}
+
+	if configParser.options.RootURL() != "https://example.com" {
+		t.Fatalf("Expected ROOT_URL to be 'https://example.com', got '%s'", configParser.options.RootURL())
+	}
+
+	if configParser.options.BasePath() != "/app" {
+		t.Fatalf("Expected BASE_PATH to be '/app', got '%s'", configParser.options.BasePath())
+	}
+
+	if err := configParser.parseLines([]string{"BASE_URL=example.com/app/"}); err == nil {
+		t.Fatal("Expected an error due to missing scheme in BASE_URL")
+	}
+}
+
+func TestWatchdogOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if !configParser.options.Watchdog() {
+		t.Fatal("Expected WATCHDOG to be enabled by default")
+	}
+
+	if !configParser.options.HasSchedulerService() {
+		t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"WATCHDOG=1"}); err != nil {
+		t.Fatal("Unexpected error:", err)
+	}
+
+	if !configParser.options.Watchdog() {
+		t.Fatal("Expected WATCHDOG to be enabled")
+	}
+
+	if !configParser.options.HasSchedulerService() {
+		t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"WATCHDOG=0"}); err != nil {
+		t.Fatal("Unexpected error:", err)
+	}
+
+	if configParser.options.Watchdog() {
+		t.Fatal("Expected WATCHDOG to be disabled")
+	}
+
+	if configParser.options.HasWatchdog() {
+		t.Fatal("Expected HAS_WATCHDOG to be disabled")
+	}
+}
+
+func TestWebAuthnOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.WebAuthn() {
+		t.Fatalf("Expected WEBAUTHN to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"WEBAUTHN=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.WebAuthn() {
+		t.Fatalf("Expected WEBAUTHN to be enabled")
+	}
+}
+
+func TestWorkerPoolSizeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.WorkerPoolSize() != 16 {
+		t.Fatalf("Expected WORKER_POOL_SIZE to be 16 by default")
+	}
+
+	if err := configParser.parseLines([]string{"WORKER_POOL_SIZE=8"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.WorkerPoolSize() != 8 {
+		t.Fatalf("Expected WORKER_POOL_SIZE to be 8")
+	}
+}
+
+func TestYouTubeAPIKeyOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.YouTubeAPIKey() != "" {
+		t.Fatalf("Expected YOUTUBE_API_KEY to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"YOUTUBE_API_KEY=somekey"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.YouTubeAPIKey() != "somekey" {
+		t.Fatalf("Expected YOUTUBE_API_KEY to be 'somekey'")
+	}
+}
+
+func TestAdminPasswordOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.AdminPassword() != "" {
+		t.Fatalf("Expected ADMIN_PASSWORD to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"ADMIN_PASSWORD=secret123"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.AdminPassword() != "secret123" {
+		t.Fatalf("Expected ADMIN_PASSWORD to be 'secret123'")
+	}
+}
+
+func TestAdminUsernameOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.AdminUsername() != "" {
+		t.Fatalf("Expected ADMIN_USERNAME to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"ADMIN_USERNAME=admin"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.AdminUsername() != "admin" {
+		t.Fatalf("Expected ADMIN_USERNAME to be 'admin'")
+	}
+}
+
+func TestAuthProxyHeaderOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.AuthProxyHeader() != "" {
+		t.Fatalf("Expected AUTH_PROXY_HEADER to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"AUTH_PROXY_HEADER=X-Forwarded-User"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.AuthProxyHeader() != "X-Forwarded-User" {
+		t.Fatalf("Expected AUTH_PROXY_HEADER to be 'X-Forwarded-User'")
+	}
+}
+
+func TestAuthProxyUserCreationOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.AuthProxyUserCreation() {
+		t.Fatal("Expected AUTH_PROXY_USER_CREATION to be disabled by default")
+	}
+
+	if configParser.options.IsAuthProxyUserCreationAllowed() {
+		t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"AUTH_PROXY_USER_CREATION=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.AuthProxyUserCreation() {
+		t.Fatal("Expected AUTH_PROXY_USER_CREATION to be enabled")
+	}
+
+	if !configParser.options.IsAuthProxyUserCreationAllowed() {
+		t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"AUTH_PROXY_USER_CREATION=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.AuthProxyUserCreation() {
+		t.Fatal("Expected AUTH_PROXY_USER_CREATION to be disabled")
+	}
+
+	if configParser.options.IsAuthProxyUserCreationAllowed() {
+		t.Fatal("Expected HAS_AUTH_PROXY_USER_CREATION to be disabled")
+	}
+}
+
+func TestBatchSizeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.BatchSize() != 100 {
+		t.Fatalf("Expected BATCH_SIZE to be 100 by default")
+	}
+
+	if err := configParser.parseLines([]string{"BATCH_SIZE=50"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.BatchSize() != 50 {
+		t.Fatalf("Expected BATCH_SIZE to be 50")
+	}
+}
+
+func TestCertDomainOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CertDomain() != "" {
+		t.Fatalf("Expected CERT_DOMAIN to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"CERT_DOMAIN=example.com"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CertDomain() != "example.com" {
+		t.Fatalf("Expected CERT_DOMAIN to be 'example.com'")
+	}
+}
+
+func TestCertFileOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CertFile() != "" {
+		t.Fatalf("Expected CERT_FILE to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"CERT_FILE=/path/to/cert.pem"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CertFile() != "/path/to/cert.pem" {
+		t.Fatalf("Expected CERT_FILE to be '/path/to/cert.pem'")
+	}
+}
+
+func TestCleanupArchiveBatchSizeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CleanupArchiveBatchSize() != 10000 {
+		t.Fatalf("Expected CLEANUP_ARCHIVE_BATCH_SIZE to be 10000 by default")
+	}
+
+	if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_BATCH_SIZE=5000"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CleanupArchiveBatchSize() != 5000 {
+		t.Fatalf("Expected CLEANUP_ARCHIVE_BATCH_SIZE to be 5000")
+	}
+}
+
+func TestCreateAdminOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CreateAdmin() {
+		t.Fatalf("Expected CREATE_ADMIN to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"CREATE_ADMIN=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.CreateAdmin() {
+		t.Fatalf("Expected CREATE_ADMIN to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"CREATE_ADMIN=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CreateAdmin() {
+		t.Fatalf("Expected CREATE_ADMIN to be disabled")
+	}
+}
+
+func TestDatabaseMaxConnsOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.DatabaseMaxConns() != 20 {
+		t.Fatalf("Expected DATABASE_MAX_CONNS to be 20 by default")
+	}
+
+	if err := configParser.parseLines([]string{"DATABASE_MAX_CONNS=10"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.DatabaseMaxConns() != 10 {
+		t.Fatalf("Expected DATABASE_MAX_CONNS to be 10")
+	}
+}
+
+func TestDatabaseMinConnsOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.DatabaseMinConns() != 1 {
+		t.Fatalf("Expected DATABASE_MIN_CONNS to be 1 by default")
+	}
+
+	if err := configParser.parseLines([]string{"DATABASE_MIN_CONNS=2"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.DatabaseMinConns() != 2 {
+		t.Fatalf("Expected DATABASE_MIN_CONNS to be 2")
+	}
+}
+
+func TestDatabaseURLOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.DatabaseURL() != "user=postgres password=postgres dbname=miniflux2 sslmode=disable" {
+		t.Fatal("Expected DATABASE_URL to have default value")
+	}
+
+	if !configParser.options.IsDefaultDatabaseURL() {
+		t.Fatal("Expected DATABASE_URL to be the default value")
+	}
+
+	if err := configParser.parseLines([]string{"DATABASE_URL=postgres://user:pass@localhost/db"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.DatabaseURL() != "postgres://user:pass@localhost/db" {
+		t.Fatal("Expected DATABASE_URL to be 'postgres://user:pass@localhost/db'")
+	}
+
+	if configParser.options.IsDefaultDatabaseURL() {
+		t.Fatal("Expected DATABASE_URL to not be the default value")
+	}
+}
+
+func TestDisableHSTSOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.DisableHSTS() {
+		t.Fatal("Expected DISABLE_HSTS to be disabled by default")
+	}
+
+	if !configParser.options.HasHSTS() {
+		t.Fatal("Expected HAS_HSTS to be enabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"DISABLE_HSTS=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.DisableHSTS() {
+		t.Fatal("Expected DISABLE_HSTS to be enabled")
+	}
+
+	if configParser.options.HasHSTS() {
+		t.Fatal("Expected HAS_HSTS to be disabled")
+	}
+
+	if err := configParser.parseLines([]string{"DISABLE_HSTS=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.DisableHSTS() {
+		t.Fatal("Expected DISABLE_HSTS to be disabled")
+	}
+
+	if !configParser.options.HasHSTS() {
+		t.Fatal("Expected HAS_HSTS to be enabled")
+	}
+}
+
+func TestDisableHTTPServiceOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.DisableHTTPService() {
+		t.Fatal("Expected DISABLE_HTTP_SERVICE to be disabled by default")
+	}
+
+	if !configParser.options.HasHTTPService() {
+		t.Fatal("Expected HAS_HTTP_SERVICE to be enabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"DISABLE_HTTP_SERVICE=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.DisableHTTPService() {
+		t.Fatal("Expected DISABLE_HTTP_SERVICE to be enabled")
+	}
+
+	if configParser.options.HasHTTPService() {
+		t.Fatal("Expected HAS_HTTP_SERVICE to be disabled")
+	}
+
+	if err := configParser.parseLines([]string{"DISABLE_HTTP_SERVICE=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.DisableHTTPService() {
+		t.Fatal("Expected DISABLE_HTTP_SERVICE to be disabled")
+	}
+
+	if !configParser.options.HasHTTPService() {
+		t.Fatal("Expected HAS_HTTP_SERVICE to be disabled")
+	}
+}
+
+func TestDisableLocalAuthOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.DisableLocalAuth() {
+		t.Fatalf("Expected DISABLE_LOCAL_AUTH to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.DisableLocalAuth() {
+		t.Fatalf("Expected DISABLE_LOCAL_AUTH to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"DISABLE_LOCAL_AUTH=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.DisableLocalAuth() {
+		t.Fatalf("Expected DISABLE_LOCAL_AUTH to be disabled")
+	}
+}
+
+func TestDisableSchedulerServiceOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.DisableSchedulerService() {
+		t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be disabled by default")
+	}
+
+	if !configParser.options.HasSchedulerService() {
+		t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"DISABLE_SCHEDULER_SERVICE=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.DisableSchedulerService() {
+		t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be enabled")
+	}
+
+	if configParser.options.HasSchedulerService() {
+		t.Fatal("Expected HAS_SCHEDULER_SERVICE to be disabled")
+	}
+
+	if err := configParser.parseLines([]string{"DISABLE_SCHEDULER_SERVICE=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.DisableSchedulerService() {
+		t.Fatal("Expected DISABLE_SCHEDULER_SERVICE to be disabled")
+	}
+
+	if !configParser.options.HasSchedulerService() {
+		t.Fatal("Expected HAS_SCHEDULER_SERVICE to be enabled")
+	}
+}
+
+func TestFetchBilibiliWatchTimeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.FetchBilibiliWatchTime() {
+		t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"FETCH_BILIBILI_WATCH_TIME=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.FetchBilibiliWatchTime() {
+		t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"FETCH_BILIBILI_WATCH_TIME=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.FetchBilibiliWatchTime() {
+		t.Fatalf("Expected FETCH_BILIBILI_WATCH_TIME to be disabled")
+	}
+}
+
+func TestFetchNebulaWatchTimeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.FetchNebulaWatchTime() {
+		t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"FETCH_NEBULA_WATCH_TIME=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.FetchNebulaWatchTime() {
+		t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"FETCH_NEBULA_WATCH_TIME=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.FetchNebulaWatchTime() {
+		t.Fatalf("Expected FETCH_NEBULA_WATCH_TIME to be disabled")
+	}
+}
+
+func TestFetchOdyseeWatchTimeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.FetchOdyseeWatchTime() {
+		t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"FETCH_ODYSEE_WATCH_TIME=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.FetchOdyseeWatchTime() {
+		t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"FETCH_ODYSEE_WATCH_TIME=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.FetchOdyseeWatchTime() {
+		t.Fatalf("Expected FETCH_ODYSEE_WATCH_TIME to be disabled")
+	}
+}
+
+func TestFetchYouTubeWatchTimeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.FetchYouTubeWatchTime() {
+		t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"FETCH_YOUTUBE_WATCH_TIME=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.FetchYouTubeWatchTime() {
+		t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"FETCH_YOUTUBE_WATCH_TIME=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.FetchYouTubeWatchTime() {
+		t.Fatalf("Expected FETCH_YOUTUBE_WATCH_TIME to be disabled")
+	}
+}
+
+func TestHTTPClientMaxBodySizeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.HTTPClientMaxBodySize() != 15*1024*1024 {
+		t.Fatalf("Expected HTTP_CLIENT_MAX_BODY_SIZE to be 15 by default, got %d", configParser.options.HTTPClientMaxBodySize())
+	}
+
+	if err := configParser.parseLines([]string{"HTTP_CLIENT_MAX_BODY_SIZE=25"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	expectedValue := 25 * 1024 * 1024
+	currentValue := configParser.options.HTTPClientMaxBodySize()
+	if currentValue != int64(expectedValue) {
+		t.Fatalf("Expected HTTP_CLIENT_MAX_BODY_SIZE to be %d, got %d", expectedValue, currentValue)
+	}
+}
+
+func TestHTTPClientUserAgentOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.HTTPClientUserAgent() != defaultHTTPClientUserAgent {
+		t.Fatalf("Expected HTTP_CLIENT_USER_AGENT to have default value")
+	}
+
+	if err := configParser.parseLines([]string{"HTTP_CLIENT_USER_AGENT=Custom User Agent"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.HTTPClientUserAgent() != "Custom User Agent" {
+		t.Fatalf("Expected HTTP_CLIENT_USER_AGENT to be 'Custom User Agent'")
+	}
+}
+
+func TestHTTPSOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.HTTPS() {
+		t.Fatalf("Expected HTTPS to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"HTTPS=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.HTTPS() {
+		t.Fatalf("Expected HTTPS to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"HTTPS=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.HTTPS() {
+		t.Fatalf("Expected HTTPS to be disabled")
+	}
+}
+
+func TestInvidiousInstanceOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.InvidiousInstance() != "yewtu.be" {
+		t.Fatalf("Expected INVIDIOUS_INSTANCE to be 'yewtu.be' by default")
+	}
+
+	if err := configParser.parseLines([]string{"INVIDIOUS_INSTANCE=invidious.example.com"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.InvidiousInstance() != "invidious.example.com" {
+		t.Fatalf("Expected INVIDIOUS_INSTANCE to be 'invidious.example.com'")
+	}
+}
+
+func TestCertKeyFileOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CertKeyFile() != "" {
+		t.Fatalf("Expected KEY_FILE to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"KEY_FILE=/path/to/key.pem"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CertKeyFile() != "/path/to/key.pem" {
+		t.Fatalf("Expected KEY_FILE to be '/path/to/key.pem'")
+	}
+}
+
+func TestLogDateTimeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.LogDateTime() {
+		t.Fatalf("Expected LOG_DATE_TIME to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"LOG_DATE_TIME=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.LogDateTime() {
+		t.Fatalf("Expected LOG_DATE_TIME to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"LOG_DATE_TIME=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.LogDateTime() {
+		t.Fatalf("Expected LOG_DATE_TIME to be disabled")
+	}
+}
+
+func TestLogFileOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.LogFile() != "stderr" {
+		t.Fatalf("Expected LOG_FILE to be 'stderr' by default")
+	}
+
+	if err := configParser.parseLines([]string{"LOG_FILE=/var/log/miniflux.log"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.LogFile() != "/var/log/miniflux.log" {
+		t.Fatalf("Expected LOG_FILE to be '/var/log/miniflux.log'")
+	}
+}
+
+func TestLogFormatOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.LogFormat() != "text" {
+		t.Fatalf("Expected LOG_FORMAT to be 'text' by default")
+	}
+
+	if err := configParser.parseLines([]string{"LOG_FORMAT=json"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.LogFormat() != "json" {
+		t.Fatalf("Expected LOG_FORMAT to be 'json'")
+	}
+}
+
+func TestLogLevelOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.LogLevel() != "info" {
+		t.Fatalf("Expected LOG_LEVEL to be 'info' by default")
+	}
+
+	if err := configParser.parseLines([]string{"LOG_LEVEL=debug"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.LogLevel() != "debug" {
+		t.Fatalf("Expected LOG_LEVEL to be 'debug'")
+	}
+}
+
+func TestMaintenanceMessageOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MaintenanceMessage() != "Miniflux is currently under maintenance" {
+		t.Fatalf("Expected MAINTENANCE_MESSAGE to have default value")
+	}
+
+	if err := configParser.parseLines([]string{"MAINTENANCE_MESSAGE=System upgrade in progress"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.MaintenanceMessage() != "System upgrade in progress" {
+		t.Fatalf("Expected MAINTENANCE_MESSAGE to be 'System upgrade in progress'")
+	}
+}
+
+func TestMaintenanceModeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MaintenanceMode() {
+		t.Fatal("Expected MAINTENANCE_MODE to be disabled by default")
+	}
+
+	if configParser.options.HasMaintenanceMode() {
+		t.Fatal("Expected HAS_MAINTENANCE_MODE to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"MAINTENANCE_MODE=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.MaintenanceMode() {
+		t.Fatal("Expected MAINTENANCE_MODE to be enabled")
+	}
+
+	if !configParser.options.HasMaintenanceMode() {
+		t.Fatal("Expected HAS_MAINTENANCE_MODE to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"MAINTENANCE_MODE=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.MaintenanceMode() {
+		t.Fatal("Expected MAINTENANCE_MODE to be disabled")
+	}
+
+	if configParser.options.HasMaintenanceMode() {
+		t.Fatal("Expected HAS_MAINTENANCE_MODE to be disabled")
+	}
+}
+
+func TestMediaProxyModeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MediaProxyMode() != "http-only" {
+		t.Fatalf("Expected MEDIA_PROXY_MODE to be 'http-only' by default")
+	}
+
+	if err := configParser.parseLines([]string{"MEDIA_PROXY_MODE=all"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.MediaProxyMode() != "all" {
+		t.Fatalf("Expected MEDIA_PROXY_MODE to be 'all'")
+	}
+}
+
+func TestMetricsCollectorOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MetricsCollector() {
+		t.Fatal("Expected METRICS_COLLECTOR to be disabled by default")
+	}
+
+	if configParser.options.HasMetricsCollector() {
+		t.Fatal("Expected HAS_METRICS_COLLECTOR to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"METRICS_COLLECTOR=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.MetricsCollector() {
+		t.Fatal("Expected METRICS_COLLECTOR to be enabled")
+	}
+
+	if !configParser.options.HasMetricsCollector() {
+		t.Fatal("Expected HAS_METRICS_COLLECTOR to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"METRICS_COLLECTOR=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.MetricsCollector() {
+		t.Fatal("Expected METRICS_COLLECTOR to be disabled")
+	}
+
+	if configParser.options.HasMetricsCollector() {
+		t.Fatal("Expected HAS_METRICS_COLLECTOR to be disabled")
+	}
+}
+
+func TestMetricsPasswordOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MetricsPassword() != "" {
+		t.Fatalf("Expected METRICS_PASSWORD to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"METRICS_PASSWORD=secret123"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.MetricsPassword() != "secret123" {
+		t.Fatalf("Expected METRICS_PASSWORD to be 'secret123'")
+	}
+}
+
+func TestMetricsUsernameOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MetricsUsername() != "" {
+		t.Fatalf("Expected METRICS_USERNAME to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"METRICS_USERNAME=metrics_user"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.MetricsUsername() != "metrics_user" {
+		t.Fatalf("Expected METRICS_USERNAME to be 'metrics_user'")
+	}
+}
+
+func TestOAuth2ClientIDOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.OAuth2ClientID() != "" {
+		t.Fatalf("Expected OAUTH2_CLIENT_ID to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_CLIENT_ID=client123"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.OAuth2ClientID() != "client123" {
+		t.Fatalf("Expected OAUTH2_CLIENT_ID to be 'client123'")
+	}
+}
+
+func TestOAuth2ClientSecretOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.OAuth2ClientSecret() != "" {
+		t.Fatalf("Expected OAUTH2_CLIENT_SECRET to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_CLIENT_SECRET=secret456"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.OAuth2ClientSecret() != "secret456" {
+		t.Fatalf("Expected OAUTH2_CLIENT_SECRET to be 'secret456'")
+	}
+}
+
+func TestOAuth2OIDCDiscoveryEndpointOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.OAuth2OIDCDiscoveryEndpoint() != "" {
+		t.Fatalf("Expected OAUTH2_OIDC_DISCOVERY_ENDPOINT to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://example.com/.well-known/openid_configuration"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.OAuth2OIDCDiscoveryEndpoint() != "https://example.com/.well-known/openid_configuration" {
+		t.Fatalf("Expected OAUTH2_OIDC_DISCOVERY_ENDPOINT to be 'https://example.com/.well-known/openid_configuration'")
+	}
+}
+
+func TestOAuth2OIDCProviderNameOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.OAuth2OIDCProviderName() != "OpenID Connect" {
+		t.Fatalf("Expected OAUTH2_OIDC_PROVIDER_NAME to be 'OpenID Connect' by default")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_OIDC_PROVIDER_NAME=My Provider"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.OAuth2OIDCProviderName() != "My Provider" {
+		t.Fatalf("Expected OAUTH2_OIDC_PROVIDER_NAME to be 'My Provider'")
+	}
+}
+
+func TestOAuth2ProviderOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.OAuth2Provider() != "" {
+		t.Fatal("Expected OAUTH2_PROVIDER to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=google"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.OAuth2Provider() != "google" {
+		t.Fatal("Expected OAUTH2_PROVIDER to be 'google'")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=oidc"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.OAuth2Provider() != "oidc" {
+		t.Fatal("Expected OAUTH2_PROVIDER to be 'oidc'")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_PROVIDER=invalid"}); err == nil {
+		t.Fatal("Expected error for invalid OAUTH2_PROVIDER value")
+	}
+}
+
+func TestOAuth2RedirectURLOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.OAuth2RedirectURL() != "" {
+		t.Fatalf("Expected OAUTH2_REDIRECT_URL to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_REDIRECT_URL=https://example.com/callback"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.OAuth2RedirectURL() != "https://example.com/callback" {
+		t.Fatalf("Expected OAUTH2_REDIRECT_URL to be 'https://example.com/callback'")
+	}
+}
+
+func TestOAuth2UserCreationOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.OAuth2UserCreation() {
+		t.Fatal("Expected OAUTH2_USER_CREATION to be disabled by default")
+	}
+
+	if configParser.options.IsOAuth2UserCreationAllowed() {
+		t.Fatal("Expected OAUTH2_USER_CREATION to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_USER_CREATION=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.OAuth2UserCreation() {
+		t.Fatal("Expected OAUTH2_USER_CREATION to be enabled")
+	}
+
+	if !configParser.options.IsOAuth2UserCreationAllowed() {
+		t.Fatal("Expected OAUTH2_USER_CREATION to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"OAUTH2_USER_CREATION=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.OAuth2UserCreation() {
+		t.Fatal("Expected OAUTH2_USER_CREATION to be disabled")
+	}
+
+	if configParser.options.IsOAuth2UserCreationAllowed() {
+		t.Fatal("Expected OAUTH2_USER_CREATION to be disabled")
+	}
+}
+
+func TestPollingLimitPerHostOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.PollingLimitPerHost() != 0 {
+		t.Fatalf("Expected POLLING_LIMIT_PER_HOST to be 0 by default")
+	}
+
+	if err := configParser.parseLines([]string{"POLLING_LIMIT_PER_HOST=5"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.PollingLimitPerHost() != 5 {
+		t.Fatalf("Expected POLLING_LIMIT_PER_HOST to be 5")
+	}
+}
+
+func TestPollingParsingErrorLimitOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.PollingParsingErrorLimit() != 3 {
+		t.Fatalf("Expected POLLING_PARSING_ERROR_LIMIT to be 3 by default")
+	}
+
+	if err := configParser.parseLines([]string{"POLLING_PARSING_ERROR_LIMIT=5"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.PollingParsingErrorLimit() != 5 {
+		t.Fatalf("Expected POLLING_PARSING_ERROR_LIMIT to be 5")
+	}
+}
+
+func TestPollingSchedulerOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.PollingScheduler() != "round_robin" {
+		t.Fatalf("Expected POLLING_SCHEDULER to be 'round_robin' by default")
+	}
+
+	if err := configParser.parseLines([]string{"POLLING_SCHEDULER=entry_frequency"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.PollingScheduler() != "entry_frequency" {
+		t.Fatalf("Expected POLLING_SCHEDULER to be 'entry_frequency'")
+	}
+}
+
+func TestPortOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.Port() != "" {
+		t.Fatalf("Expected PORT to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"PORT=1234"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.Port() != "1234" {
+		t.Fatalf("Expected PORT to be '1234'")
+	}
+
+	addresses := configParser.options.ListenAddr()
+	if len(addresses) != 1 || addresses[0] != ":1234" {
+		t.Fatalf("Expected LISTEN_ADDR to be ':1234'")
+	}
+}
+
+func TestRunMigrationsOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.RunMigrations() {
+		t.Fatalf("Expected RUN_MIGRATIONS to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"RUN_MIGRATIONS=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.RunMigrations() {
+		t.Fatalf("Expected RUN_MIGRATIONS to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"RUN_MIGRATIONS=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.RunMigrations() {
+		t.Fatalf("Expected RUN_MIGRATIONS to be disabled")
+	}
+}
+
+func TestSchedulerEntryFrequencyFactorOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.SchedulerEntryFrequencyFactor() != 1 {
+		t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 1 by default")
+	}
+
+	if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_FACTOR=2"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.SchedulerEntryFrequencyFactor() != 2 {
+		t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_FACTOR to be 2")
+	}
+}
+
+func TestYouTubeEmbedUrlOverrideOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	// Test default value
+	if configParser.options.YouTubeEmbedUrlOverride() != "https://www.youtube-nocookie.com/embed/" {
+		t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to have default value")
+	}
+
+	if configParser.options.YouTubeEmbedDomain() != "www.youtube-nocookie.com" {
+		t.Fatal("Expected YOUTUBE_EMBED_DOMAIN to be 'www.youtube-nocookie.com' by default")
+	}
+
+	// Test custom value
+	if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=https://custom.youtube.com/embed/"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.YouTubeEmbedUrlOverride() != "https://custom.youtube.com/embed/" {
+		t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to be 'https://custom.youtube.com/embed/'")
+	}
+
+	if configParser.options.YouTubeEmbedDomain() != "custom.youtube.com" {
+		t.Fatal("Expected YOUTUBE_EMBED_DOMAIN to be 'custom.youtube.com'")
+	}
+
+	// Test empty value resets to default
+	configParser = NewConfigParser()
+	if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE="}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.YouTubeEmbedUrlOverride() != "https://www.youtube-nocookie.com/embed/" {
+		t.Fatal("Expected YOUTUBE_EMBED_URL_OVERRIDE to have default value")
+	}
+
+	// Test invalid value
+	configParser = NewConfigParser()
+	if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=http://example.com/%"}); err == nil {
+		t.Fatal("Expected error for invalid YOUTUBE_EMBED_URL_OVERRIDE")
+	}
+}
+
+func TestCleanupArchiveReadIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CleanupArchiveReadInterval().Hours() != 24*60 {
+		t.Fatalf("Expected CLEANUP_ARCHIVE_READ_DAYS to be 60 days by default")
+	}
+
+	if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_READ_DAYS=30"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CleanupArchiveReadInterval().Hours() != 24*30 {
+		t.Fatalf("Expected CLEANUP_ARCHIVE_READ_DAYS to be 30 days")
+	}
+}
+
+func TestCleanupArchiveUnreadIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CleanupArchiveUnreadInterval().Hours() != 24*180 {
+		t.Fatalf("Expected CLEANUP_ARCHIVE_UNREAD_DAYS to be 180 days by default")
+	}
+
+	if err := configParser.parseLines([]string{"CLEANUP_ARCHIVE_UNREAD_DAYS=90"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CleanupArchiveUnreadInterval().Hours() != 24*90 {
+		t.Fatalf("Expected CLEANUP_ARCHIVE_UNREAD_DAYS to be 90 days")
+	}
+}
+
+func TestCleanupFrequencyOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CleanupFrequency().Hours() != 24 {
+		t.Fatalf("Expected CLEANUP_FREQUENCY_HOURS to be 24 hours by default")
+	}
+
+	if err := configParser.parseLines([]string{"CLEANUP_FREQUENCY_HOURS=12"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CleanupFrequency().Hours() != 12 {
+		t.Fatalf("Expected CLEANUP_FREQUENCY_HOURS to be 12 hours")
+	}
+}
+
+func TestCleanupRemoveSessionsIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.CleanupRemoveSessionsInterval().Hours() != 24*30 {
+		t.Fatalf("Expected CLEANUP_REMOVE_SESSIONS_DAYS to be 30 days by default")
+	}
+
+	if err := configParser.parseLines([]string{"CLEANUP_REMOVE_SESSIONS_DAYS=14"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.CleanupRemoveSessionsInterval().Hours() != 24*14 {
+		t.Fatalf("Expected CLEANUP_REMOVE_SESSIONS_DAYS to be 14 days")
+	}
+}
+
+func TestDatabaseConnectionLifetimeOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.DatabaseConnectionLifetime().Minutes() != 5 {
+		t.Fatalf("Expected DATABASE_CONNECTION_LIFETIME to be 5 minutes by default")
+	}
+
+	if err := configParser.parseLines([]string{"DATABASE_CONNECTION_LIFETIME=10"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.DatabaseConnectionLifetime().Minutes() != 10 {
+		t.Fatalf("Expected DATABASE_CONNECTION_LIFETIME to be 10 minutes")
+	}
+}
+
+func TestFilterEntryMaxAgeDaysOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.FilterEntryMaxAgeDays() != 0 {
+		t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 0 by default")
+	}
+
+	if err := configParser.parseLines([]string{"FILTER_ENTRY_MAX_AGE_DAYS=7"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.FilterEntryMaxAgeDays() != 7 {
+		t.Fatalf("Expected FILTER_ENTRY_MAX_AGE_DAYS to be 7 days")
+	}
+}
+
+func TestForceRefreshIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.ForceRefreshInterval().Minutes() != 30 {
+		t.Fatalf("Expected FORCE_REFRESH_INTERVAL to be 30 minutes by default")
+	}
+
+	if err := configParser.parseLines([]string{"FORCE_REFRESH_INTERVAL=15"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.ForceRefreshInterval().Minutes() != 15 {
+		t.Fatalf("Expected FORCE_REFRESH_INTERVAL to be 15 minutes")
+	}
+}
+
+func TestHTTPClientProxiesOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.HasHTTPClientProxiesConfigured() {
+		t.Fatalf("Expected HTTP_CLIENT_PROXIES to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"HTTP_CLIENT_PROXIES=proxy1.example.com:8080,proxy2.example.com:8080"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.HasHTTPClientProxiesConfigured() {
+		t.Fatalf("Expected HTTP_CLIENT_PROXIES to be configured")
+	}
+
+	proxies := configParser.options.HTTPClientProxies()
+	if len(proxies) != 2 || proxies[0] != "proxy1.example.com:8080" || proxies[1] != "proxy2.example.com:8080" {
+		t.Fatalf("Expected HTTP_CLIENT_PROXIES to contain two proxies")
+	}
+}
+
+func TestHTTPClientProxyOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.HTTPClientProxyURL() != nil {
+		t.Fatal("Expected HTTP_CLIENT_PROXY to be nil by default")
+	}
+
+	if configParser.options.HasHTTPClientProxyURLConfigured() {
+		t.Fatal("Expected HAS_HTTP_CLIENT_PROXY to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"HTTP_CLIENT_PROXY=http://proxy.example.com:8080"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	proxyURL := configParser.options.HTTPClientProxyURL()
+	if proxyURL == nil || proxyURL.String() != "http://proxy.example.com:8080" {
+		t.Fatal("Expected HTTP_CLIENT_PROXY to be 'http://proxy.example.com:8080'")
+	}
+
+	if !configParser.options.HasHTTPClientProxyURLConfigured() {
+		t.Fatal("Expected HAS_HTTP_CLIENT_PROXY to be enabled")
+	}
+}
+
+func TestHTTPClientTimeoutOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.HTTPClientTimeout().Seconds() != 20 {
+		t.Fatalf("Expected HTTP_CLIENT_TIMEOUT to be 20 seconds by default")
+	}
+
+	if err := configParser.parseLines([]string{"HTTP_CLIENT_TIMEOUT=30"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.HTTPClientTimeout().Seconds() != 30 {
+		t.Fatalf("Expected HTTP_CLIENT_TIMEOUT to be 30 seconds")
+	}
+}
+
+func TestHTTPServerTimeoutOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.HTTPServerTimeout().Seconds() != 300 {
+		t.Fatal("Expected HTTP_SERVER_TIMEOUT to be 300 seconds by default")
+	}
+
+	if err := configParser.parseLines([]string{"HTTP_SERVER_TIMEOUT=60"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.HTTPServerTimeout().Seconds() != 60 {
+		t.Fatal("Expected HTTP_SERVER_TIMEOUT to be 60 seconds")
+	}
+}
+
+func TestListenAddrOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	addrs := configParser.options.ListenAddr()
+	if len(addrs) != 1 || addrs[0] != "127.0.0.1:8080" {
+		t.Fatalf("Expected LISTEN_ADDR to be '127.0.0.1:8080' by default")
+	}
+
+	if err := configParser.parseLines([]string{"LISTEN_ADDR=0.0.0.0:8080,127.0.0.1:8081,/unix.socket"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	addrs = configParser.options.ListenAddr()
+	if len(addrs) != 3 || addrs[0] != "0.0.0.0:8080" || addrs[1] != "127.0.0.1:8081" || addrs[2] != "/unix.socket" {
+		t.Fatalf("Expected LISTEN_ADDR to contain two addresses")
+	}
+}
+
+func TestMediaCustomProxyURLOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MediaCustomProxyURL() != nil {
+		t.Fatalf("Expected MEDIA_PROXY_CUSTOM_URL to be nil by default")
+	}
+
+	if err := configParser.parseLines([]string{"MEDIA_PROXY_CUSTOM_URL=https://proxy.example.com"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	proxyURL := configParser.options.MediaCustomProxyURL()
+	if proxyURL == nil || proxyURL.String() != "https://proxy.example.com" {
+		t.Fatalf("Expected MEDIA_PROXY_CUSTOM_URL to be 'https://proxy.example.com'")
+	}
+}
+
+func TestMediaProxyHTTPClientTimeoutOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MediaProxyHTTPClientTimeout().Seconds() != 120 {
+		t.Fatalf("Expected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT to be 120 seconds by default")
+	}
+
+	if err := configParser.parseLines([]string{"MEDIA_PROXY_HTTP_CLIENT_TIMEOUT=60"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.MediaProxyHTTPClientTimeout().Seconds() != 60 {
+		t.Fatalf("Expected MEDIA_PROXY_HTTP_CLIENT_TIMEOUT to be 60 seconds")
+	}
+}
+
+func TestMediaProxyPrivateKeyOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if len(configParser.options.MediaProxyPrivateKey()) != 0 {
+		t.Fatalf("Expected MEDIA_PROXY_PRIVATE_KEY to be empty by default")
+	}
+
+	if err := configParser.parseLines([]string{"MEDIA_PROXY_PRIVATE_KEY=secret123"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	privateKey := configParser.options.MediaProxyPrivateKey()
+	if string(privateKey) != "secret123" {
+		t.Fatalf("Expected MEDIA_PROXY_PRIVATE_KEY to be 'secret123'")
+	}
+}
+
+func TestMediaProxyResourceTypesOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	resourceTypes := configParser.options.MediaProxyResourceTypes()
+	if len(resourceTypes) != 1 || resourceTypes[0] != "image" {
+		t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to have default values")
+	}
+
+	if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,video"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	resourceTypes = configParser.options.MediaProxyResourceTypes()
+	if len(resourceTypes) != 2 || resourceTypes[0] != "image" || resourceTypes[1] != "video" {
+		t.Fatalf("Expected MEDIA_PROXY_RESOURCE_TYPES to contain image and video")
+	}
+
+	if err := configParser.parseLines([]string{"MEDIA_PROXY_RESOURCE_TYPES=image,invalid,video"}); err == nil {
+		t.Fatal("Expected error due to invalid resource type")
+	}
+}
+
+func TestMetricsAllowedNetworksOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	networks := configParser.options.MetricsAllowedNetworks()
+	if len(networks) != 1 || networks[0] != "127.0.0.1/8" {
+		t.Fatalf("Expected METRICS_ALLOWED_NETWORKS to have default values")
+	}
+
+	if err := configParser.parseLines([]string{"METRICS_ALLOWED_NETWORKS=10.0.0.0/8,192.168.0.0/16"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	networks = configParser.options.MetricsAllowedNetworks()
+	if len(networks) != 2 || networks[0] != "10.0.0.0/8" || networks[1] != "192.168.0.0/16" {
+		t.Fatalf("Expected METRICS_ALLOWED_NETWORKS to contain specified networks")
+	}
+}
+
+func TestMetricsRefreshIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.MetricsRefreshInterval().Seconds() != 60 {
+		t.Fatalf("Expected METRICS_REFRESH_INTERVAL to be 60 seconds by default")
+	}
+
+	if err := configParser.parseLines([]string{"METRICS_REFRESH_INTERVAL=120"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.MetricsRefreshInterval().Seconds() != 120 {
+		t.Fatalf("Expected METRICS_REFRESH_INTERVAL to be 120 seconds")
+	}
+}
+
+func TestPollingFrequencyOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.PollingFrequency().Minutes() != 60 {
+		t.Fatalf("Expected POLLING_FREQUENCY to be 60 minutes by default")
+	}
+
+	if err := configParser.parseLines([]string{"POLLING_FREQUENCY=30"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.PollingFrequency().Minutes() != 30 {
+		t.Fatalf("Expected POLLING_FREQUENCY to be 30 minutes")
+	}
+}
+
+func TestSchedulerEntryFrequencyMaxIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.SchedulerEntryFrequencyMaxInterval().Hours() != 24 {
+		t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL to be 24 hours by default")
+	}
+
+	if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL=720"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.SchedulerEntryFrequencyMaxInterval().Hours() != 12 {
+		t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL to be 12 hours")
+	}
+}
+
+func TestSchedulerEntryFrequencyMinIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.SchedulerEntryFrequencyMinInterval().Minutes() != 5 {
+		t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL to be 5 minutes by default")
+	}
+
+	if err := configParser.parseLines([]string{"SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL=10"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.SchedulerEntryFrequencyMinInterval().Minutes() != 10 {
+		t.Fatalf("Expected SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL to be 10 minutes")
+	}
+}
+
+func TestSchedulerRoundRobinMaxIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.SchedulerRoundRobinMaxInterval().Hours() != 24 {
+		t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL to be 24 hours by default")
+	}
+
+	if err := configParser.parseLines([]string{"SCHEDULER_ROUND_ROBIN_MAX_INTERVAL=60"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.SchedulerRoundRobinMaxInterval().Hours() != 1 {
+		t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MAX_INTERVAL to be 1 hour")
+	}
+}
+
+func TestSchedulerRoundRobinMinIntervalOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.SchedulerRoundRobinMinInterval().Minutes() != 60 {
+		t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL to be 60 minutes by default")
+	}
+
+	if err := configParser.parseLines([]string{"SCHEDULER_ROUND_ROBIN_MIN_INTERVAL=30"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.SchedulerRoundRobinMinInterval().Minutes() != 30 {
+		t.Fatalf("Expected SCHEDULER_ROUND_ROBIN_MIN_INTERVAL to be 30 minutes")
+	}
+}
+
+func TestYouTubeEmbedDomainOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.YouTubeEmbedDomain() != "www.youtube-nocookie.com" {
+		t.Fatalf("Expected YouTubeEmbedDomain to be 'www.youtube-nocookie.com' by default")
+	}
+
+	// YouTube embed domain is derived from YOUTUBE_EMBED_URL_OVERRIDE
+	if err := configParser.parseLines([]string{"YOUTUBE_EMBED_URL_OVERRIDE=https://custom.youtube.com/embed/"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.YouTubeEmbedDomain() != "custom.youtube.com" {
+		t.Fatalf("Expected YouTubeEmbedDomain to be 'custom.youtube.com'")
+	}
+}
+
+func TestSetLogLevelFunction(t *testing.T) {
+	configParser := NewConfigParser()
+
+	// Test default log level
+	if configParser.options.LogLevel() != "info" {
+		t.Fatalf("Expected LOG_LEVEL to be 'info' by default, got '%s'", configParser.options.LogLevel())
+	}
+
+	// Test setting log level to debug
+	configParser.options.SetLogLevel("debug")
+	if configParser.options.LogLevel() != "debug" {
+		t.Fatalf("Expected LOG_LEVEL to be 'debug' after SetLogLevel('debug'), got '%s'", configParser.options.LogLevel())
+	}
+	if configParser.options.options["LOG_LEVEL"].RawValue != "debug" {
+		t.Fatalf("Expected LOG_LEVEL RawValue to be 'debug', got '%s'", configParser.options.options["LOG_LEVEL"].RawValue)
+	}
+
+	// Test setting log level to warning
+	configParser.options.SetLogLevel("warning")
+	if configParser.options.LogLevel() != "warning" {
+		t.Fatalf("Expected LOG_LEVEL to be 'warning' after SetLogLevel('warning'), got '%s'", configParser.options.LogLevel())
+	}
+	if configParser.options.options["LOG_LEVEL"].RawValue != "warning" {
+		t.Fatalf("Expected LOG_LEVEL RawValue to be 'warning', got '%s'", configParser.options.options["LOG_LEVEL"].RawValue)
+	}
+}
+
+func TestSetHTTPSValueFunction(t *testing.T) {
+	configParser := NewConfigParser()
+
+	// Test setting HTTPS to true
+	configParser.options.SetHTTPSValue(true)
+	if !configParser.options.HTTPS() {
+		t.Fatalf("Expected HTTPS to be true after SetHTTPSValue(true)")
+	}
+
+	// Test setting HTTPS to false
+	configParser.options.SetHTTPSValue(false)
+	if configParser.options.HTTPS() {
+		t.Fatalf("Expected HTTPS to be false after SetHTTPSValue(false)")
+	}
+
+	// Test setting HTTPS to true again
+	configParser.options.SetHTTPSValue(true)
+	if !configParser.options.HTTPS() {
+		t.Fatalf("Expected HTTPS to be true after second SetHTTPSValue(true)")
+	}
+}
+
+func TestConfigMap(t *testing.T) {
+	configMap := NewConfigOptions().ConfigMap(false)
+
+	if len(configMap) == 0 {
+		t.Fatal("Expected ConfigMap to contain configuration options")
+	}
+
+	// The first option should be "ADMIN_PASSWORD"
+	if configMap[0].Key != "ADMIN_PASSWORD" {
+		t.Fatalf("Expected first config option to be 'ADMIN_PASSWORD', got '%s'", configMap[0].Key)
+	}
+}
+
+func TestConfigMapWithRedactedSecrets(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if err := configParser.parseLines([]string{"ADMIN_PASSWORD=secret123"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	configMap := configParser.options.ConfigMap(true)
+
+	if len(configMap) == 0 {
+		t.Fatal("Expected ConfigMap to contain configuration options")
+	}
+
+	// The first option should be "ADMIN_PASSWORD"
+	if configMap[0].Key != "ADMIN_PASSWORD" {
+		t.Fatalf("Expected first config option to be 'ADMIN_PASSWORD', got '%s'", configMap[0].Key)
+	}
+
+	// The value should be redacted
+	if configMap[0].Value != "<redacted>" {
+		t.Fatalf("Expected ADMIN_PASSWORD value to be redacted, got '%s'", configMap[0].Value)
+	}
+}

+ 181 - 257
internal/config/parser.go

@@ -7,7 +7,6 @@ import (
 	"bufio"
 	"bytes"
 	"crypto/rand"
-	"errors"
 	"fmt"
 	"io"
 	"net/url"
@@ -17,301 +16,205 @@ import (
 	"time"
 )
 
-// parser handles configuration parsing.
-type parser struct {
-	opts *options
+type configParser struct {
+	options *configOptions
 }
 
-// NewParser returns a new Parser.
-func NewParser() *parser {
-	return &parser{
-		opts: NewOptions(),
+func NewConfigParser() *configParser {
+	return &configParser{
+		options: NewConfigOptions(),
 	}
 }
 
-// ParseEnvironmentVariables loads configuration values from environment variables.
-func (p *parser) ParseEnvironmentVariables() (*options, error) {
-	err := p.parseLines(os.Environ())
-	if err != nil {
+func (cp *configParser) ParseEnvironmentVariables() (*configOptions, error) {
+	if err := cp.parseLines(os.Environ()); err != nil {
 		return nil, err
 	}
-	return p.opts, nil
+
+	return cp.options, nil
 }
 
-// ParseFile loads configuration values from a local file.
-func (p *parser) ParseFile(filename string) (*options, error) {
+func (cp *configParser) ParseFile(filename string) (*configOptions, error) {
 	fp, err := os.Open(filename)
 	if err != nil {
 		return nil, err
 	}
 	defer fp.Close()
 
-	err = p.parseLines(p.parseFileContent(fp))
-	if err != nil {
+	if err := cp.parseLines(parseFileContent(fp)); err != nil {
 		return nil, err
 	}
-	return p.opts, nil
+
+	return cp.options, nil
 }
 
-func (p *parser) parseFileContent(r io.Reader) (lines []string) {
-	scanner := bufio.NewScanner(r)
-	for scanner.Scan() {
-		line := strings.TrimSpace(scanner.Text())
-		if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
-			lines = append(lines, line)
+func (cp *configParser) postParsing() error {
+	// Parse basePath and rootURL based on BASE_URL
+	baseURL := cp.options.options["BASE_URL"].ParsedStringValue
+	baseURL = strings.TrimSuffix(baseURL, "/")
+
+	parsedURL, err := url.Parse(baseURL)
+	if err != nil {
+		return fmt.Errorf("invalid BASE_URL: %v", err)
+	}
+
+	scheme := strings.ToLower(parsedURL.Scheme)
+	if scheme != "https" && scheme != "http" {
+		return fmt.Errorf("BASE_URL scheme must be http or https")
+	}
+
+	cp.options.options["BASE_URL"].ParsedStringValue = baseURL
+	cp.options.basePath = parsedURL.Path
+
+	parsedURL.Path = ""
+	cp.options.rootURL = parsedURL.String()
+
+	// Parse YouTube embed domain based on YOUTUBE_EMBED_URL_OVERRIDE
+	youTubeEmbedURLOverride := cp.options.options["YOUTUBE_EMBED_URL_OVERRIDE"].ParsedStringValue
+	if youTubeEmbedURLOverride != "" {
+		parsedYouTubeEmbedURL, err := url.Parse(youTubeEmbedURLOverride)
+		if err != nil {
+			return fmt.Errorf("invalid YOUTUBE_EMBED_URL_OVERRIDE: %v", err)
 		}
+		cp.options.youTubeEmbedDomain = parsedYouTubeEmbedURL.Hostname()
 	}
-	return lines
-}
 
-func (p *parser) parseLines(lines []string) (err error) {
-	var port string
+	// Generate a media proxy private key if not set
+	if len(cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue) == 0 {
+		randomKey := make([]byte, 16)
+		rand.Read(randomKey)
+		cp.options.options["MEDIA_PROXY_PRIVATE_KEY"].ParsedBytesValue = randomKey
+	}
 
+	// Override LISTEN_ADDR with PORT if set (for compatibility reasons)
+	if cp.options.Port() != "" {
+		cp.options.options["LISTEN_ADDR"].ParsedStringList = []string{":" + cp.options.Port()}
+		cp.options.options["LISTEN_ADDR"].RawValue = ":" + cp.options.Port()
+	}
+
+	return nil
+}
+
+func (cp *configParser) parseLines(lines []string) error {
 	for lineNum, line := range lines {
 		key, value, ok := strings.Cut(line, "=")
 		if !ok {
-			return fmt.Errorf("config: unable to parse configuration, invalid format on line %d", lineNum)
+			return fmt.Errorf("unable to parse configuration, invalid format on line %d", lineNum)
 		}
-		key, value = strings.TrimSpace(key), strings.TrimSpace(value)
 
-		switch key {
-		case "LOG_FILE":
-			p.opts.logFile = parseString(value, defaultLogFile)
-		case "LOG_DATE_TIME":
-			p.opts.logDateTime = parseBool(value, defaultLogDateTime)
-		case "LOG_LEVEL":
-			parsedValue := parseString(value, defaultLogLevel)
-			if parsedValue == "debug" || parsedValue == "info" || parsedValue == "warning" || parsedValue == "error" {
-				p.opts.logLevel = parsedValue
-			}
-		case "LOG_FORMAT":
-			parsedValue := parseString(value, defaultLogFormat)
-			if parsedValue == "json" || parsedValue == "text" {
-				p.opts.logFormat = parsedValue
-			}
-		case "BASE_URL":
-			p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
-			if err != nil {
-				return err
-			}
-		case "PORT":
-			port = value
-		case "LISTEN_ADDR":
-			p.opts.listenAddr = parseStringList(value, []string{defaultListenAddr})
-		case "DATABASE_URL":
-			p.opts.databaseURL = parseString(value, defaultDatabaseURL)
-		case "DATABASE_URL_FILE":
-			p.opts.databaseURL = readSecretFile(value, defaultDatabaseURL)
-		case "DATABASE_MAX_CONNS":
-			p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
-		case "DATABASE_MIN_CONNS":
-			p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
-		case "DATABASE_CONNECTION_LIFETIME":
-			p.opts.databaseConnectionLifetime = parseInt(value, defaultDatabaseConnectionLifetime)
-		case "FILTER_ENTRY_MAX_AGE_DAYS":
-			p.opts.filterEntryMaxAgeDays = parseInt(value, defaultFilterEntryMaxAgeDays)
-		case "RUN_MIGRATIONS":
-			p.opts.runMigrations = parseBool(value, defaultRunMigrations)
-		case "DISABLE_HSTS":
-			p.opts.hsts = !parseBool(value, defaultHSTS)
-		case "HTTPS":
-			p.opts.HTTPS = parseBool(value, defaultHTTPS)
-		case "DISABLE_SCHEDULER_SERVICE":
-			p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
-		case "DISABLE_HTTP_SERVICE":
-			p.opts.httpService = !parseBool(value, defaultHTTPService)
-		case "CERT_FILE":
-			p.opts.certFile = parseString(value, defaultCertFile)
-		case "KEY_FILE":
-			p.opts.certKeyFile = parseString(value, defaultKeyFile)
-		case "CERT_DOMAIN":
-			p.opts.certDomain = parseString(value, defaultCertDomain)
-		case "CLEANUP_FREQUENCY_HOURS":
-			p.opts.cleanupFrequencyInterval = parseInterval(value, time.Hour, defaultCleanupFrequency)
-		case "CLEANUP_ARCHIVE_READ_DAYS":
-			p.opts.cleanupArchiveReadInterval = parseInterval(value, 24*time.Hour, defaultCleanupArchiveReadInterval)
-		case "CLEANUP_ARCHIVE_UNREAD_DAYS":
-			p.opts.cleanupArchiveUnreadInterval = parseInterval(value, 24*time.Hour, defaultCleanupArchiveUnreadInterval)
-		case "CLEANUP_ARCHIVE_BATCH_SIZE":
-			p.opts.cleanupArchiveBatchSize = parseInt(value, defaultCleanupArchiveBatchSize)
-		case "CLEANUP_REMOVE_SESSIONS_DAYS":
-			p.opts.cleanupRemoveSessionsInterval = parseInterval(value, 24*time.Hour, defaultCleanupRemoveSessionsInterval)
-		case "WORKER_POOL_SIZE":
-			p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
-		case "FORCE_REFRESH_INTERVAL":
-			p.opts.forceRefreshInterval = parseInterval(value, time.Minute, defaultForceRefreshInterval)
-		case "BATCH_SIZE":
-			p.opts.batchSize = parseInt(value, defaultBatchSize)
-		case "POLLING_FREQUENCY":
-			p.opts.pollingFrequency = parseInterval(value, time.Minute, defaultPollingFrequency)
-		case "POLLING_LIMIT_PER_HOST":
-			p.opts.pollingLimitPerHost = parseInt(value, 0)
-		case "POLLING_PARSING_ERROR_LIMIT":
-			p.opts.pollingParsingErrorLimit = parseInt(value, defaultPollingParsingErrorLimit)
-		case "POLLING_SCHEDULER":
-			p.opts.pollingScheduler = strings.ToLower(parseString(value, defaultPollingScheduler))
-		case "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL":
-			p.opts.schedulerEntryFrequencyMaxInterval = parseInterval(value, time.Minute, defaultSchedulerEntryFrequencyMaxInterval)
-		case "SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL":
-			p.opts.schedulerEntryFrequencyMinInterval = parseInterval(value, time.Minute, defaultSchedulerEntryFrequencyMinInterval)
-		case "SCHEDULER_ENTRY_FREQUENCY_FACTOR":
-			p.opts.schedulerEntryFrequencyFactor = parseInt(value, defaultSchedulerEntryFrequencyFactor)
-		case "SCHEDULER_ROUND_ROBIN_MIN_INTERVAL":
-			p.opts.schedulerRoundRobinMinInterval = parseInterval(value, time.Minute, defaultSchedulerRoundRobinMinInterval)
-		case "SCHEDULER_ROUND_ROBIN_MAX_INTERVAL":
-			p.opts.schedulerRoundRobinMaxInterval = parseInterval(value, time.Minute, defaultSchedulerRoundRobinMaxInterval)
-		case "MEDIA_PROXY_HTTP_CLIENT_TIMEOUT":
-			p.opts.mediaProxyHTTPClientTimeout = parseInterval(value, time.Second, defaultMediaProxyHTTPClientTimeout)
-		case "MEDIA_PROXY_MODE":
-			p.opts.mediaProxyMode = parseString(value, defaultMediaProxyMode)
-		case "MEDIA_PROXY_RESOURCE_TYPES":
-			p.opts.mediaProxyResourceTypes = parseStringList(value, []string{defaultMediaResourceTypes})
-		case "MEDIA_PROXY_PRIVATE_KEY":
-			randomKey := make([]byte, 16)
-			rand.Read(randomKey)
-			p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
-		case "MEDIA_PROXY_CUSTOM_URL":
-			p.opts.mediaProxyCustomURL, err = url.Parse(parseString(value, defaultMediaProxyURL))
-			if err != nil {
-				return fmt.Errorf("config: invalid MEDIA_PROXY_CUSTOM_URL value: %w", err)
-			}
-		case "CREATE_ADMIN":
-			p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
-		case "ADMIN_USERNAME":
-			p.opts.adminUsername = parseString(value, defaultAdminUsername)
-		case "ADMIN_USERNAME_FILE":
-			p.opts.adminUsername = readSecretFile(value, defaultAdminUsername)
-		case "ADMIN_PASSWORD":
-			p.opts.adminPassword = parseString(value, defaultAdminPassword)
-		case "ADMIN_PASSWORD_FILE":
-			p.opts.adminPassword = readSecretFile(value, defaultAdminPassword)
-		case "OAUTH2_USER_CREATION":
-			p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
-		case "OAUTH2_CLIENT_ID":
-			p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
-		case "OAUTH2_CLIENT_ID_FILE":
-			p.opts.oauth2ClientID = readSecretFile(value, defaultOAuth2ClientID)
-		case "OAUTH2_CLIENT_SECRET":
-			p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
-		case "OAUTH2_CLIENT_SECRET_FILE":
-			p.opts.oauth2ClientSecret = readSecretFile(value, defaultOAuth2ClientSecret)
-		case "OAUTH2_REDIRECT_URL":
-			p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
-		case "OAUTH2_OIDC_DISCOVERY_ENDPOINT":
-			p.opts.oidcDiscoveryEndpoint = parseString(value, defaultOAuth2OidcDiscoveryEndpoint)
-		case "OAUTH2_OIDC_PROVIDER_NAME":
-			p.opts.oidcProviderName = parseString(value, defaultOauth2OidcProviderName)
-		case "OAUTH2_PROVIDER":
-			p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
-		case "DISABLE_LOCAL_AUTH":
-			p.opts.disableLocalAuth = parseBool(value, defaultDisableLocalAuth)
-		case "HTTP_CLIENT_TIMEOUT":
-			p.opts.httpClientTimeout = parseInterval(value, time.Second, defaultHTTPClientTimeout)
-		case "HTTP_CLIENT_MAX_BODY_SIZE":
-			p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
-		case "HTTP_CLIENT_PROXY":
-			p.opts.httpClientProxyURL, err = url.Parse(parseString(value, defaultHTTPClientProxy))
-			if err != nil {
-				return fmt.Errorf("config: invalid HTTP_CLIENT_PROXY value: %w", err)
-			}
-		case "HTTP_CLIENT_PROXIES":
-			p.opts.httpClientProxies = parseStringList(value, []string{})
-		case "HTTP_CLIENT_USER_AGENT":
-			p.opts.httpClientUserAgent = parseString(value, defaultHTTPClientUserAgent)
-		case "HTTP_SERVER_TIMEOUT":
-			p.opts.httpServerTimeout = parseInterval(value, time.Second, defaultHTTPServerTimeout)
-		case "AUTH_PROXY_HEADER":
-			p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
-		case "AUTH_PROXY_USER_CREATION":
-			p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
-		case "MAINTENANCE_MODE":
-			p.opts.maintenanceMode = parseBool(value, defaultMaintenanceMode)
-		case "MAINTENANCE_MESSAGE":
-			p.opts.maintenanceMessage = parseString(value, defaultMaintenanceMessage)
-		case "METRICS_COLLECTOR":
-			p.opts.metricsCollector = parseBool(value, defaultMetricsCollector)
-		case "METRICS_REFRESH_INTERVAL":
-			p.opts.metricsRefreshInterval = parseInterval(value, time.Second, defaultMetricsRefreshInterval)
-		case "METRICS_ALLOWED_NETWORKS":
-			p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
-		case "METRICS_USERNAME":
-			p.opts.metricsUsername = parseString(value, defaultMetricsUsername)
-		case "METRICS_USERNAME_FILE":
-			p.opts.metricsUsername = readSecretFile(value, defaultMetricsUsername)
-		case "METRICS_PASSWORD":
-			p.opts.metricsPassword = parseString(value, defaultMetricsPassword)
-		case "METRICS_PASSWORD_FILE":
-			p.opts.metricsPassword = readSecretFile(value, defaultMetricsPassword)
-		case "FETCH_BILIBILI_WATCH_TIME":
-			p.opts.fetchBilibiliWatchTime = parseBool(value, defaultFetchBilibiliWatchTime)
-		case "FETCH_NEBULA_WATCH_TIME":
-			p.opts.fetchNebulaWatchTime = parseBool(value, defaultFetchNebulaWatchTime)
-		case "FETCH_ODYSEE_WATCH_TIME":
-			p.opts.fetchOdyseeWatchTime = parseBool(value, defaultFetchOdyseeWatchTime)
-		case "FETCH_YOUTUBE_WATCH_TIME":
-			p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
-		case "YOUTUBE_API_KEY":
-			p.opts.youTubeApiKey = parseString(value, defaultYouTubeApiKey)
-		case "YOUTUBE_EMBED_URL_OVERRIDE":
-			p.opts.youTubeEmbedUrlOverride = parseString(value, defaultYouTubeEmbedUrlOverride)
-		case "WATCHDOG":
-			p.opts.watchdog = parseBool(value, defaultWatchdog)
-		case "INVIDIOUS_INSTANCE":
-			p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
-		case "WEBAUTHN":
-			p.opts.webAuthn = parseBool(value, defaultWebAuthn)
+		key, value = strings.TrimSpace(key), strings.TrimSpace(value)
+		if err := cp.parseLine(key, value); err != nil {
+			return err
 		}
 	}
 
-	if port != "" {
-		p.opts.listenAddr = []string{":" + port}
+	if err := cp.postParsing(); err != nil {
+		return err
 	}
 
-	youtubeEmbedURL, err := url.Parse(p.opts.youTubeEmbedUrlOverride)
-	if err != nil {
-		return fmt.Errorf("config: invalid YOUTUBE_EMBED_URL_OVERRIDE value: %w", err)
-	}
-	p.opts.youTubeEmbedDomain = youtubeEmbedURL.Hostname()
-
 	return nil
 }
 
-func parseBaseURL(value string) (string, string, string, error) {
-	if value == "" {
-		return defaultBaseURL, defaultRootURL, "", nil
+func (cp *configParser) parseLine(key, value string) error {
+	field, exists := cp.options.options[key]
+	if !exists {
+		// Ignore unknown configuration keys to avoid parsing unrelated environment variables.
+		return nil
 	}
 
-	value = strings.TrimSuffix(value, "/")
-
-	parsedURL, err := url.Parse(value)
-	if err != nil {
-		return "", "", "", fmt.Errorf("config: invalid BASE_URL: %w", err)
+	// Validate the option if a validator is provided
+	if field.Validator != nil {
+		if err := field.Validator(value); err != nil {
+			return fmt.Errorf("invalid value for key %s: %v", key, err)
+		}
 	}
 
-	scheme := strings.ToLower(parsedURL.Scheme)
-	if scheme != "https" && scheme != "http" {
-		return "", "", "", errors.New("config: invalid BASE_URL: scheme must be http or https")
+	// Convert the raw value based on its type
+	switch field.ValueType {
+	case stringType:
+		field.ParsedStringValue = parseStringValue(value, field.ParsedStringValue)
+		field.RawValue = value
+	case stringListType:
+		field.ParsedStringList = parseStringListValue(value, field.ParsedStringList)
+		field.RawValue = value
+	case boolType:
+		parsedValue, err := parseBoolValue(value, field.ParsedBoolValue)
+		if err != nil {
+			return fmt.Errorf("invalid boolean value for key %s: %v", key, err)
+		}
+		field.ParsedBoolValue = parsedValue
+		field.RawValue = value
+	case intType:
+		field.ParsedIntValue = parseIntValue(value, field.ParsedIntValue)
+		field.RawValue = value
+	case int64Type:
+		field.ParsedInt64Value = ParsedInt64Value(value, field.ParsedInt64Value)
+		field.RawValue = value
+	case secondType:
+		field.ParsedDuration = parseDurationValue(value, time.Second, field.ParsedDuration)
+		field.RawValue = value
+	case minuteType:
+		field.ParsedDuration = parseDurationValue(value, time.Minute, field.ParsedDuration)
+		field.RawValue = value
+	case hourType:
+		field.ParsedDuration = parseDurationValue(value, time.Hour, field.ParsedDuration)
+		field.RawValue = value
+	case dayType:
+		field.ParsedDuration = parseDurationValue(value, time.Hour*24, field.ParsedDuration)
+		field.RawValue = value
+	case urlType:
+		parsedURL, err := parseURLValue(value, field.ParsedURLValue)
+		if err != nil {
+			return fmt.Errorf("invalid URL for key %s: %v", key, err)
+		}
+		field.ParsedURLValue = parsedURL
+		field.RawValue = value
+	case secretFileType:
+		secretValue, err := readSecretFileValue(value)
+		if err != nil {
+			return fmt.Errorf("error reading secret file for key %s: %v", key, err)
+		}
+		if field.TargetKey != "" {
+			if targetField, ok := cp.options.options[field.TargetKey]; ok {
+				targetField.ParsedStringValue = secretValue
+				targetField.RawValue = secretValue
+			}
+		}
+		field.RawValue = value
+	case bytesType:
+		if value != "" {
+			field.ParsedBytesValue = []byte(value)
+			field.RawValue = value
+		}
 	}
 
-	basePath := parsedURL.Path
-	parsedURL.Path = ""
-	return value, parsedURL.String(), basePath, nil
+	return nil
 }
 
-func parseBool(value string, fallback bool) bool {
+func parseStringValue(value string, fallback string) string {
 	if value == "" {
 		return fallback
 	}
+	return value
+}
+
+func parseBoolValue(value string, fallback bool) (bool, error) {
+	if value == "" {
+		return fallback, nil
+	}
 
 	value = strings.ToLower(value)
 	if value == "1" || value == "yes" || value == "true" || value == "on" {
-		return true
+		return true, nil
+	}
+	if value == "0" || value == "no" || value == "false" || value == "off" {
+		return false, nil
 	}
 
-	return false
+	return false, fmt.Errorf("invalid boolean value: %q", value)
 }
 
-func parseInt(value string, fallback int) int {
+func parseIntValue(value string, fallback int) int {
 	if value == "" {
 		return fallback
 	}
@@ -324,14 +227,20 @@ func parseInt(value string, fallback int) int {
 	return v
 }
 
-func parseString(value string, fallback string) string {
+func ParsedInt64Value(value string, fallback int64) int64 {
 	if value == "" {
 		return fallback
 	}
-	return value
+
+	v, err := strconv.ParseInt(value, 10, 64)
+	if err != nil {
+		return fallback
+	}
+
+	return v
 }
 
-func parseStringList(value string, fallback []string) []string {
+func parseStringListValue(value string, fallback []string) []string {
 	if value == "" {
 		return fallback
 	}
@@ -351,38 +260,53 @@ func parseStringList(value string, fallback []string) []string {
 	return strList
 }
 
-func parseBytes(value string, fallback []byte) []byte {
+func parseDurationValue(value string, unit time.Duration, fallback time.Duration) time.Duration {
 	if value == "" {
 		return fallback
 	}
 
-	return []byte(value)
+	v, err := strconv.Atoi(value)
+	if err != nil {
+		return fallback
+	}
+
+	return time.Duration(v) * unit
 }
 
-// parseInterval converts an integer "value" to [time.Duration] using "unit" as multiplier.
-func parseInterval(value string, unit time.Duration, fallback time.Duration) time.Duration {
+func parseURLValue(value string, fallback *url.URL) (*url.URL, error) {
 	if value == "" {
-		return fallback
+		return fallback, nil
 	}
 
-	v, err := strconv.Atoi(value)
+	parsedURL, err := url.Parse(value)
 	if err != nil {
-		return fallback
+		return fallback, err
 	}
 
-	return time.Duration(v) * unit
+	return parsedURL, nil
 }
 
-func readSecretFile(filename, fallback string) string {
+func readSecretFileValue(filename string) (string, error) {
 	data, err := os.ReadFile(filename)
 	if err != nil {
-		return fallback
+		return "", err
 	}
 
 	value := string(bytes.TrimSpace(data))
 	if value == "" {
-		return fallback
+		return "", fmt.Errorf("secret file is empty")
 	}
 
-	return value
+	return value, nil
+}
+
+func parseFileContent(r io.Reader) (lines []string) {
+	scanner := bufio.NewScanner(r)
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
+			lines = append(lines, line)
+		}
+	}
+	return lines
 }

+ 406 - 137
internal/config/parser_test.go

@@ -4,163 +4,432 @@
 package config // import "miniflux.app/v2/internal/config"
 
 import (
+	"net/url"
+	"os"
 	"reflect"
 	"testing"
+	"time"
 )
 
+func TestParseStringValue(t *testing.T) {
+	// Test with non-empty value
+	result := parseStringValue("test", "fallback")
+	if result != "test" {
+		t.Errorf("Expected 'test', got '%s'", result)
+	}
+
+	// Test with empty value
+	result = parseStringValue("", "fallback")
+	if result != "fallback" {
+		t.Errorf("Expected 'fallback', got '%s'", result)
+	}
+
+	// Test with empty value and empty fallback
+	result = parseStringValue("", "")
+	if result != "" {
+		t.Errorf("Expected empty string, got '%s'", result)
+	}
+}
+
 func TestParseBoolValue(t *testing.T) {
-	scenarios := map[string]bool{
-		"":        true,
-		"1":       true,
-		"Yes":     true,
-		"yes":     true,
-		"True":    true,
-		"true":    true,
-		"on":      true,
-		"false":   false,
-		"off":     false,
-		"invalid": false,
-	}
-
-	for input, expected := range scenarios {
-		result := parseBool(input, true)
-		if result != expected {
-			t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
+	// Test with empty value - should return fallback
+	result, err := parseBoolValue("", true)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if result != true {
+		t.Errorf("Expected true, got %v", result)
+	}
+
+	// Test true values
+	trueValues := []string{"1", "yes", "true", "on", "YES", "TRUE", "ON"}
+	for _, value := range trueValues {
+		result, err := parseBoolValue(value, false)
+		if err != nil {
+			t.Errorf("Unexpected error for value '%s': %v", value, err)
+		}
+		if result != true {
+			t.Errorf("Expected true for '%s', got %v", value, result)
+		}
+	}
+
+	// Test false values
+	falseValues := []string{"0", "no", "false", "off", "NO", "FALSE", "OFF"}
+	for _, value := range falseValues {
+		result, err := parseBoolValue(value, true)
+		if err != nil {
+			t.Errorf("Unexpected error for value '%s': %v", value, err)
 		}
+		if result != false {
+			t.Errorf("Expected false for '%s', got %v", value, result)
+		}
+	}
+
+	// Test invalid value - should return error
+	_, err = parseBoolValue("invalid", false)
+	if err == nil {
+		t.Error("Expected error for invalid boolean value")
 	}
 }
 
-func TestParseStringValueWithUnsetVariable(t *testing.T) {
-	if parseString("", "defaultValue") != "defaultValue" {
-		t.Errorf(`Unset variables should returns the default value`)
+func TestParseIntValue(t *testing.T) {
+	// Test with empty value - should return fallback
+	result := parseIntValue("", 42)
+	if result != 42 {
+		t.Errorf("Expected 42, got %d", result)
+	}
+
+	// Test with valid integer
+	result = parseIntValue("123", 42)
+	if result != 123 {
+		t.Errorf("Expected 123, got %d", result)
+	}
+
+	// Test with invalid integer - should return fallback
+	result = parseIntValue("invalid", 42)
+	if result != 42 {
+		t.Errorf("Expected 42, got %d", result)
+	}
+
+	// Test with zero
+	result = parseIntValue("0", 42)
+	if result != 0 {
+		t.Errorf("Expected 0, got %d", result)
 	}
 }
 
-func TestParseStringValue(t *testing.T) {
-	if parseString("test", "defaultValue") != "test" {
-		t.Errorf(`Defined variables should returns the specified value`)
+func TestParsedInt64Value(t *testing.T) {
+	// Test with empty value - should return fallback
+	result := ParsedInt64Value("", 42)
+	if result != 42 {
+		t.Errorf("Expected 42, got %d", result)
+	}
+
+	// Test with valid int64
+	result = ParsedInt64Value("9223372036854775807", 42)
+	if result != 9223372036854775807 {
+		t.Errorf("Expected 9223372036854775807, got %d", result)
+	}
+
+	// Test with invalid int64 - should return fallback
+	result = ParsedInt64Value("invalid", 42)
+	if result != 42 {
+		t.Errorf("Expected 42, got %d", result)
+	}
+}
+
+func TestParseStringListValue(t *testing.T) {
+	// Test with empty value - should return fallback
+	fallback := []string{"a", "b"}
+	result := parseStringListValue("", fallback)
+	if !reflect.DeepEqual(result, fallback) {
+		t.Errorf("Expected %v, got %v", fallback, result)
+	}
+
+	// Test with single value
+	result = parseStringListValue("item1", nil)
+	expected := []string{"item1"}
+	if !reflect.DeepEqual(result, expected) {
+		t.Errorf("Expected %v, got %v", expected, result)
+	}
+
+	// Test with multiple values
+	result = parseStringListValue("item1,item2,item3", nil)
+	expected = []string{"item1", "item2", "item3"}
+	if !reflect.DeepEqual(result, expected) {
+		t.Errorf("Expected %v, got %v", expected, result)
+	}
+
+	// Test with duplicates - should remove duplicates
+	result = parseStringListValue("item1,item2,item1", nil)
+	expected = []string{"item1", "item2"}
+	if !reflect.DeepEqual(result, expected) {
+		t.Errorf("Expected %v, got %v", expected, result)
+	}
+
+	// Test with spaces
+	result = parseStringListValue(" item1 , item2 , item3 ", nil)
+	expected = []string{"item1", "item2", "item3"}
+	if !reflect.DeepEqual(result, expected) {
+		t.Errorf("Expected %v, got %v", expected, result)
 	}
 }
 
-func TestParseIntValueWithUnsetVariable(t *testing.T) {
-	if parseInt("", 42) != 42 {
-		t.Errorf(`Unset variables should returns the default value`)
+func TestParseDurationValue(t *testing.T) {
+	// Test with empty value - should return fallback
+	fallback := 5 * time.Second
+	result := parseDurationValue("", time.Second, fallback)
+	if result != fallback {
+		t.Errorf("Expected %v, got %v", fallback, result)
+	}
+
+	// Test with valid duration
+	result = parseDurationValue("30", time.Second, fallback)
+	expected := 30 * time.Second
+	if result != expected {
+		t.Errorf("Expected %v, got %v", expected, result)
+	}
+
+	// Test with minutes
+	result = parseDurationValue("5", time.Minute, fallback)
+	expected = 5 * time.Minute
+	if result != expected {
+		t.Errorf("Expected %v, got %v", expected, result)
+	}
+
+	// Test with invalid value - should return fallback
+	result = parseDurationValue("invalid", time.Second, fallback)
+	if result != fallback {
+		t.Errorf("Expected %v, got %v", fallback, result)
 	}
 }
 
-func TestParseIntValueWithInvalidInput(t *testing.T) {
-	if parseInt("invalid integer", 42) != 42 {
-		t.Errorf(`Invalid integer should returns the default value`)
+func TestParseURLValue(t *testing.T) {
+	// Test with empty value - should return fallback
+	fallbackURL, _ := url.Parse("https://fallback.com")
+	result, err := parseURLValue("", fallbackURL)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if result != fallbackURL {
+		t.Errorf("Expected %v, got %v", fallbackURL, result)
+	}
+
+	// Test with valid URL
+	result, err = parseURLValue("https://example.com", nil)
+	if err != nil {
+		t.Errorf("Unexpected error: %v", err)
+	}
+	if result.String() != "https://example.com" {
+		t.Errorf("Expected https://example.com, got %s", result.String())
+	}
+
+	// Test with invalid URL - should return fallback and error
+	result, err = parseURLValue("://invalid", fallbackURL)
+	if err == nil {
+		t.Error("Expected error for invalid URL")
+	}
+	if result != fallbackURL {
+		t.Errorf("Expected fallback URL, got %v", result)
 	}
 }
 
-func TestParseIntValue(t *testing.T) {
-	if parseInt("2018", 42) != 2018 {
-		t.Errorf(`Defined variables should returns the specified value`)
-	}
-}
-
-func TestParseListenAddr(t *testing.T) {
-	defaultExpected := []string{defaultListenAddr}
-
-	tests := []struct {
-		name           string
-		listenAddr     string
-		port           string
-		expected       []string
-		lines          []string // Used for direct lines parsing instead of individual env vars
-		isLineOriented bool     // Flag to indicate if we use lines
-	}{
-		{
-			name:       "Single LISTEN_ADDR",
-			listenAddr: "127.0.0.1:8080",
-			expected:   []string{"127.0.0.1:8080"},
-		},
-		{
-			name:       "Multiple LISTEN_ADDR comma-separated",
-			listenAddr: "127.0.0.1:8080,:8081,/tmp/miniflux.sock",
-			expected:   []string{"127.0.0.1:8080", ":8081", "/tmp/miniflux.sock"},
-		},
-		{
-			name:       "Multiple LISTEN_ADDR with spaces around commas",
-			listenAddr: "127.0.0.1:8080 , :8081",
-			expected:   []string{"127.0.0.1:8080", ":8081"},
-		},
-		{
-			name:       "Empty LISTEN_ADDR",
-			listenAddr: "",
-			expected:   defaultExpected,
-		},
-		{
-			name:       "PORT overrides LISTEN_ADDR",
-			listenAddr: "127.0.0.1:8000",
-			port:       "8082",
-			expected:   []string{":8082"},
-		},
-		{
-			name:       "PORT overrides empty LISTEN_ADDR",
-			listenAddr: "",
-			port:       "8083",
-			expected:   []string{":8083"},
-		},
-		{
-			name:       "LISTEN_ADDR with empty segment (comma)",
-			listenAddr: "127.0.0.1:8080,,:8081",
-			expected:   []string{"127.0.0.1:8080", ":8081"},
-		},
-		{
-			name:           "PORT override with lines parsing",
-			isLineOriented: true,
-			lines:          []string{"LISTEN_ADDR=127.0.0.1:8000", "PORT=8082"},
-			expected:       []string{":8082"},
-		},
-		{
-			name:           "LISTEN_ADDR only with lines parsing (comma)",
-			isLineOriented: true,
-			lines:          []string{"LISTEN_ADDR=10.0.0.1:9090,10.0.0.2:9091"},
-			expected:       []string{"10.0.0.1:9090", "10.0.0.2:9091"},
-		},
-		{
-			name:           "Empty LISTEN_ADDR with lines parsing (default)",
-			isLineOriented: true,
-			lines:          []string{"LISTEN_ADDR="},
-			expected:       defaultExpected,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			parser := NewParser()
-			var err error
-
-			if tt.isLineOriented {
-				err = parser.parseLines(tt.lines)
-			} else {
-				// Simulate os.Environ() behaviour for individual var testing
-				var envLines []string
-				if tt.listenAddr != "" {
-					envLines = append(envLines, "LISTEN_ADDR="+tt.listenAddr)
-				}
-				if tt.port != "" {
-					envLines = append(envLines, "PORT="+tt.port)
-				}
-				// Add a dummy var if both are empty to avoid empty lines slice if not intended
-				if tt.listenAddr == "" && tt.port == "" && tt.name == "Empty LISTEN_ADDR" {
-					// This case specifically tests empty LISTEN_ADDR resulting in default
-					// So, we pass LISTEN_ADDR=
-					envLines = append(envLines, "LISTEN_ADDR=")
-				}
-				err = parser.parseLines(envLines)
-			}
-
-			if err != nil {
-				t.Fatalf("parseLines() error = %v", err)
-			}
-
-			opts := parser.opts
-			if !reflect.DeepEqual(opts.ListenAddr(), tt.expected) {
-				t.Errorf("ListenAddr() got = %v, want %v", opts.ListenAddr(), tt.expected)
-			}
-		})
+func TestConfigFileParsing(t *testing.T) {
+	fileContent := `
+		# This is a comment
+		LOG_FILE=miniflux.log
+		LOG_DATE_TIME=1
+		LOG_FORMAT=json
+		LISTEN_ADDR=:8080,:8443
+	`
+
+	// Write a temporary config file and parse it
+	tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
+	if err != nil {
+		t.Fatalf("Failed to create temporary file: %v", err)
+	}
+
+	defer os.Remove(tmpFile.Name())
+	defer tmpFile.Close()
+
+	filename := tmpFile.Name()
+	if _, err := tmpFile.WriteString(fileContent); err != nil {
+		t.Fatalf("Failed to write to temporary file: %v", err)
+	}
+
+	configParser := NewConfigParser()
+	configOptions, err := configParser.ParseFile(filename)
+	if err != nil {
+		t.Fatalf("Unexpected parsing error: %v", err)
+	}
+
+	if configOptions.LogFile() != "miniflux.log" {
+		t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
+	}
+
+	if configOptions.LogDateTime() != true {
+		t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
+	}
+
+	if configOptions.LogFormat() != "json" {
+		t.Fatalf("Unexpected log format, got %q", configOptions.LogFormat())
+	}
+
+	if configOptions.LogLevel() != "info" {
+		t.Fatalf("Unexpected log level, got %q", configOptions.LogLevel())
+	}
+
+	if len(configOptions.ListenAddr()) != 2 || configOptions.ListenAddr()[0] != ":8080" || configOptions.ListenAddr()[1] != ":8443" {
+		t.Fatalf("Unexpected listen addresses, got %v", configOptions.ListenAddr())
+	}
+}
+
+func TestConfigFileParsingWithIncorrectKeyValuePair(t *testing.T) {
+	fileContent := `
+		LOG_FILE=miniflux.log
+		INVALID_LINE
+	`
+
+	// Write a temporary config file and parse it
+	tmpFile, err := os.CreateTemp("", "miniflux-*.txt")
+	if err != nil {
+		t.Fatalf("Failed to create temporary file: %v", err)
+	}
+
+	defer os.Remove(tmpFile.Name())
+	defer tmpFile.Close()
+
+	filename := tmpFile.Name()
+	if _, err := tmpFile.WriteString(fileContent); err != nil {
+		t.Fatalf("Failed to write to temporary file: %v", err)
+	}
+
+	configParser := NewConfigParser()
+	_, err = configParser.ParseFile(filename)
+	if err != nil {
+		t.Fatal("Invalid lines should be ignored, but got error:", err)
+	}
+}
+
+func TestParseAdminPasswordFileOption(t *testing.T) {
+	tmpFile, err := os.CreateTemp("", "password-*.txt")
+	if err != nil {
+		t.Fatalf("Failed to create temporary file: %v", err)
+	}
+	defer os.Remove(tmpFile.Name())
+	defer tmpFile.Close()
+
+	password := "supersecret"
+	if _, err := tmpFile.WriteString(password); err != nil {
+		t.Fatalf("Failed to write to temporary file: %v", err)
+	}
+
+	os.Clearenv()
+	os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
+
+	configParser := NewConfigParser()
+	configOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unexpected parsing error: %v", err)
+	}
+
+	if configOptions.AdminPassword() != password {
+		t.Fatalf("Unexpected admin password, got %q", configOptions.AdminPassword())
+	}
+}
+
+func TestParseAdminPasswordFileOptionWithEmptyFile(t *testing.T) {
+	tmpFile, err := os.CreateTemp("", "empty-password-*.txt")
+	if err != nil {
+		t.Fatalf("Failed to create temporary file: %v", err)
+	}
+	defer os.Remove(tmpFile.Name())
+	defer tmpFile.Close()
+
+	os.Clearenv()
+	os.Setenv("ADMIN_PASSWORD_FILE", tmpFile.Name())
+
+	configParser := NewConfigParser()
+	_, err = configParser.ParseEnvironmentVariables()
+	if err == nil {
+		t.Fatal("Expected error due to empty password file, but got none")
+	}
+}
+
+func TestParseLogFileOptionDefaultValue(t *testing.T) {
+	os.Clearenv()
+
+	configParser := NewConfigParser()
+	configOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unexpected parsing error: %v", err)
+	}
+
+	if configOptions.LogFile() != "stderr" {
+		t.Fatalf("Unexpected default log file, got %q", configOptions.LogFile())
+	}
+}
+
+func TestParseLogFileOptionWithCustomFilename(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("LOG_FILE", "miniflux.log")
+
+	configParser := NewConfigParser()
+	configOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unexpected parsing error: %v", err)
+	}
+
+	if configOptions.LogFile() != "miniflux.log" {
+		t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
+	}
+}
+
+func TestParseLogFileOptionWithEmptyValue(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("LOG_FILE", "")
+
+	configParser := NewConfigParser()
+	configOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unexpected parsing error: %v", err)
+	}
+
+	if configOptions.LogFile() != "stderr" {
+		t.Fatalf("Unexpected log file, got %q", configOptions.LogFile())
+	}
+}
+
+func TestParseLogDateTimeOptionDefaultValue(t *testing.T) {
+	os.Clearenv()
+
+	configParser := NewConfigParser()
+	configOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unexpected parsing error: %v", err)
+	}
+
+	if configOptions.LogDateTime() != false {
+		t.Fatalf("Unexpected default log datetime, got %v", configOptions.LogDateTime())
+	}
+}
+
+func TestParseLogDateTimeOptionWithCustomValue(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("LOG_DATE_TIME", "true")
+
+	configParser := NewConfigParser()
+	configOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unexpected parsing error: %v", err)
+	}
+
+	if configOptions.LogDateTime() != true {
+		t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
+	}
+}
+
+func TestParseLogDateTimeOptionWithEmptyValue(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("LOG_DATE_TIME", "")
+
+	configParser := NewConfigParser()
+	configOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unexpected parsing error: %v", err)
+	}
+
+	if configOptions.LogDateTime() != false {
+		t.Fatalf("Unexpected log datetime, got %v", configOptions.LogDateTime())
+	}
+}
+
+func TestParseLogDateTimeOptionWithIncorrectValue(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("LOG_DATE_TIME", "invalid")
+
+	configParser := NewConfigParser()
+	if _, err := configParser.ParseEnvironmentVariables(); err == nil {
+		t.Fatal("Expected parsing error, got nil")
 	}
 }

+ 60 - 0
internal/config/validators.go

@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package config // import "miniflux.app/v2/internal/config"
+
+import (
+	"fmt"
+	"slices"
+	"strconv"
+	"strings"
+)
+
+func validateChoices(rawValue string, choices []string) error {
+	if !slices.Contains(choices, rawValue) {
+		return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
+	}
+	return nil
+}
+
+func validateListChoices(inputValues, choices []string) error {
+	for _, value := range inputValues {
+		if !slices.Contains(choices, value) {
+			return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
+		}
+	}
+	return nil
+}
+
+func validateGreaterThan(rawValue string, min int) error {
+	intValue, err := strconv.Atoi(rawValue)
+	if err != nil {
+		return fmt.Errorf("value must be an integer")
+	}
+	if intValue > min {
+		return nil
+	}
+	return fmt.Errorf("value must be at least %d", min)
+}
+
+func validateGreaterOrEqualThan(rawValue string, min int) error {
+	intValue, err := strconv.Atoi(rawValue)
+	if err != nil {
+		return fmt.Errorf("value must be an integer")
+	}
+	if intValue >= min {
+		return nil
+	}
+	return fmt.Errorf("value must be greater or equal than %d", min)
+}
+
+func validateRange(rawValue string, min, max int) error {
+	intValue, err := strconv.Atoi(rawValue)
+	if err != nil {
+		return fmt.Errorf("value must be an integer")
+	}
+	if intValue < min || intValue > max {
+		return fmt.Errorf("value must be between %d and %d", min, max)
+	}
+	return nil
+}

+ 372 - 0
internal/config/validators_test.go

@@ -0,0 +1,372 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package config // import "miniflux.app/v2/internal/config"
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestValidateChoices(t *testing.T) {
+	tests := []struct {
+		name        string
+		rawValue    string
+		choices     []string
+		expectError bool
+	}{
+		{
+			name:        "valid choice",
+			rawValue:    "option1",
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: false,
+		},
+		{
+			name:        "valid choice from middle",
+			rawValue:    "option2",
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: false,
+		},
+		{
+			name:        "valid choice from end",
+			rawValue:    "option3",
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: false,
+		},
+		{
+			name:        "invalid choice",
+			rawValue:    "invalid",
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: true,
+		},
+		{
+			name:        "empty value with non-empty choices",
+			rawValue:    "",
+			choices:     []string{"option1", "option2"},
+			expectError: true,
+		},
+		{
+			name:        "case sensitive - different case",
+			rawValue:    "OPTION1",
+			choices:     []string{"option1", "option2"},
+			expectError: true,
+		},
+		{
+			name:        "single choice valid",
+			rawValue:    "only",
+			choices:     []string{"only"},
+			expectError: false,
+		},
+		{
+			name:        "empty choices list",
+			rawValue:    "anything",
+			choices:     []string{},
+			expectError: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateChoices(tt.rawValue, tt.choices)
+			if tt.expectError {
+				if err == nil {
+					t.Errorf("expected error but got none")
+				} else {
+					// Verify error message format
+					expectedPrefix := "value must be one of:"
+					if !strings.Contains(err.Error(), expectedPrefix) {
+						t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
+					}
+				}
+			} else {
+				if err != nil {
+					t.Errorf("expected no error but got: %v", err)
+				}
+			}
+		})
+	}
+}
+
+func TestValidateListChoices(t *testing.T) {
+	tests := []struct {
+		name        string
+		inputValues []string
+		choices     []string
+		expectError bool
+	}{
+		{
+			name:        "all valid choices",
+			inputValues: []string{"option1", "option2"},
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: false,
+		},
+		{
+			name:        "single valid choice",
+			inputValues: []string{"option1"},
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: false,
+		},
+		{
+			name:        "empty input list",
+			inputValues: []string{},
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: false,
+		},
+		{
+			name:        "all choices from available list",
+			inputValues: []string{"option1", "option2", "option3"},
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: false,
+		},
+		{
+			name:        "duplicate valid choices",
+			inputValues: []string{"option1", "option1", "option2"},
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: false,
+		},
+		{
+			name:        "one invalid choice",
+			inputValues: []string{"option1", "invalid"},
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: true,
+		},
+		{
+			name:        "all invalid choices",
+			inputValues: []string{"invalid1", "invalid2"},
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: true,
+		},
+		{
+			name:        "case sensitive - different case",
+			inputValues: []string{"OPTION1"},
+			choices:     []string{"option1", "option2"},
+			expectError: true,
+		},
+		{
+			name:        "empty string in input",
+			inputValues: []string{""},
+			choices:     []string{"option1", "option2"},
+			expectError: true,
+		},
+		{
+			name:        "empty choices list with non-empty input",
+			inputValues: []string{"anything"},
+			choices:     []string{},
+			expectError: true,
+		},
+		{
+			name:        "mixed valid and invalid choices",
+			inputValues: []string{"option1", "invalid", "option2"},
+			choices:     []string{"option1", "option2", "option3"},
+			expectError: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateListChoices(tt.inputValues, tt.choices)
+			if tt.expectError {
+				if err == nil {
+					t.Errorf("expected error but got none")
+				} else {
+					// Verify error message format
+					expectedPrefix := "value must be one of:"
+					if !strings.Contains(err.Error(), expectedPrefix) {
+						t.Errorf("error message should contain '%s', got: %s", expectedPrefix, err.Error())
+					}
+				}
+			} else {
+				if err != nil {
+					t.Errorf("expected no error but got: %v", err)
+				}
+			}
+		})
+	}
+}
+
+func TestValidateGreaterThan(t *testing.T) {
+	if err := validateGreaterThan("10", 5); err != nil {
+		t.Errorf("expected no error, got: %v", err)
+	}
+
+	if err := validateGreaterThan("5", 5); err == nil {
+		t.Errorf("expected error, got none")
+	}
+
+	if err := validateGreaterThan("abc", 5); err == nil {
+		t.Errorf("expected error for non-integer input, got none")
+	}
+
+	if err := validateGreaterThan("-1", 0); err == nil {
+		t.Errorf("expected error for value below minimum, got none")
+	}
+}
+
+func TestValidateGreaterOrEqualThan(t *testing.T) {
+	if err := validateGreaterOrEqualThan("10", 5); err != nil {
+		t.Errorf("expected no error, got: %v", err)
+	}
+
+	if err := validateGreaterOrEqualThan("5", 5); err != nil {
+		t.Errorf("expected no error for equal value, got: %v", err)
+	}
+
+	if err := validateGreaterOrEqualThan("abc", 5); err == nil {
+		t.Errorf("expected error for non-integer input, got none")
+	}
+
+	if err := validateGreaterOrEqualThan("-1", 0); err == nil {
+		t.Errorf("expected error for value below minimum, got none")
+	}
+}
+
+func TestValidateRange(t *testing.T) {
+	tests := []struct {
+		name        string
+		rawValue    string
+		min         int
+		max         int
+		expectError bool
+		errorMsg    string
+	}{
+		{
+			name:        "valid integer within range",
+			rawValue:    "5",
+			min:         1,
+			max:         10,
+			expectError: false,
+		},
+		{
+			name:        "valid integer at minimum",
+			rawValue:    "1",
+			min:         1,
+			max:         10,
+			expectError: false,
+		},
+		{
+			name:        "valid integer at maximum",
+			rawValue:    "10",
+			min:         1,
+			max:         10,
+			expectError: false,
+		},
+		{
+			name:        "valid zero in range",
+			rawValue:    "0",
+			min:         -5,
+			max:         5,
+			expectError: false,
+		},
+		{
+			name:        "valid negative in range",
+			rawValue:    "-3",
+			min:         -5,
+			max:         5,
+			expectError: false,
+		},
+		{
+			name:        "integer below minimum",
+			rawValue:    "0",
+			min:         1,
+			max:         10,
+			expectError: true,
+			errorMsg:    "value must be between 1 and 10",
+		},
+		{
+			name:        "integer above maximum",
+			rawValue:    "11",
+			min:         1,
+			max:         10,
+			expectError: true,
+			errorMsg:    "value must be between 1 and 10",
+		},
+		{
+			name:        "integer far below minimum",
+			rawValue:    "-100",
+			min:         1,
+			max:         10,
+			expectError: true,
+			errorMsg:    "value must be between 1 and 10",
+		},
+		{
+			name:        "integer far above maximum",
+			rawValue:    "100",
+			min:         1,
+			max:         10,
+			expectError: true,
+			errorMsg:    "value must be between 1 and 10",
+		},
+		{
+			name:        "non-integer string",
+			rawValue:    "abc",
+			min:         1,
+			max:         10,
+			expectError: true,
+			errorMsg:    "value must be an integer",
+		},
+		{
+			name:        "empty string",
+			rawValue:    "",
+			min:         1,
+			max:         10,
+			expectError: true,
+			errorMsg:    "value must be an integer",
+		},
+		{
+			name:        "float string",
+			rawValue:    "5.5",
+			min:         1,
+			max:         10,
+			expectError: true,
+			errorMsg:    "value must be an integer",
+		},
+		{
+			name:        "string with spaces",
+			rawValue:    " 5 ",
+			min:         1,
+			max:         10,
+			expectError: true,
+			errorMsg:    "value must be an integer",
+		},
+		{
+			name:        "single value range",
+			rawValue:    "5",
+			min:         5,
+			max:         5,
+			expectError: false,
+		},
+		{
+			name:        "single value range - below",
+			rawValue:    "4",
+			min:         5,
+			max:         5,
+			expectError: true,
+			errorMsg:    "value must be between 5 and 5",
+		},
+		{
+			name:        "single value range - above",
+			rawValue:    "6",
+			min:         5,
+			max:         5,
+			expectError: true,
+			errorMsg:    "value must be between 5 and 5",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateRange(tt.rawValue, tt.min, tt.max)
+			if tt.expectError {
+				if err == nil {
+					t.Errorf("expected error but got none")
+				} else if tt.errorMsg != "" && err.Error() != tt.errorMsg {
+					t.Errorf("expected error message '%s', got '%s'", tt.errorMsg, err.Error())
+				}
+			} else {
+				if err != nil {
+					t.Errorf("expected no error but got: %v", err)
+				}
+			}
+		})
+	}
+}

+ 3 - 3
internal/http/server/httpd.go

@@ -60,7 +60,7 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
 				slog.Error("ACME HTTP challenge server failed", slog.Any("error", err))
 			}
 		}()
-		config.Opts.HTTPS = true
+		config.Opts.SetHTTPSValue(true)
 		httpServers = append(httpServers, challengeServer)
 	}
 
@@ -95,7 +95,7 @@ func StartWebServer(store *storage.Storage, pool *worker.Pool) []*http.Server {
 		case certFile != "" && keyFile != "":
 			server.Addr = listenAddr
 			startTLSServer(server, certFile, keyFile)
-			config.Opts.HTTPS = true
+			config.Opts.SetHTTPSValue(true)
 		default:
 			server.Addr = listenAddr
 			startHTTPServer(server)
@@ -148,7 +148,7 @@ func startUnixSocketServer(server *http.Server, socketFile string) {
 				slog.String("key_file", keyFile),
 			)
 			// Ensure HTTPS is marked as true if any listener uses TLS
-			config.Opts.HTTPS = true
+			config.Opts.SetHTTPSValue(true)
 			if err := server.ServeTLS(listener, certFile, keyFile); err != http.ErrServerClosed {
 				printErrorAndExit("TLS Unix socket server failed to start on %s: %v", socketFile, err)
 			}

+ 2 - 2
internal/http/server/middleware.go

@@ -20,7 +20,7 @@ func middleware(next http.Handler) http.Handler {
 		ctx = context.WithValue(ctx, request.ClientIPContextKey, clientIP)
 
 		if r.Header.Get("X-Forwarded-Proto") == "https" {
-			config.Opts.HTTPS = true
+			config.Opts.SetHTTPSValue(true)
 		}
 
 		t1 := time.Now()
@@ -36,7 +36,7 @@ func middleware(next http.Handler) http.Handler {
 			)
 		}()
 
-		if config.Opts.HTTPS && config.Opts.HasHSTS() {
+		if config.Opts.HTTPS() && config.Opts.HasHSTS() {
 			w.Header().Set("Strict-Transport-Security", "max-age=31536000")
 		}
 

+ 24 - 62
internal/mediaproxy/media_proxy_test.go

@@ -19,7 +19,7 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -43,7 +43,7 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -66,7 +66,7 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_MODE", "none")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -89,7 +89,7 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_MODE", "none")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -114,7 +114,7 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -139,7 +139,7 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -164,7 +164,7 @@ func TestAbsoluteProxyFilterWithHttpsAlways(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -188,7 +188,7 @@ func TestAbsoluteProxyFilterWithCustomPortAndSubfolderInBaseURL(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -226,7 +226,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndAudioTag(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -251,7 +251,7 @@ func TestProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -276,7 +276,7 @@ func TestProxyFilterWithHttpsAlwaysAndIncorrectCustomProxyServer(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_CUSTOM_URL", "http://:8080example.com")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err == nil {
 		t.Fatalf(`Incorrect proxy URL silently accepted (MEDIA_PROXY_CUSTOM_URL=%q): %q`, os.Getenv("MEDIA_PROXY_CUSTOM_URL"), config.Opts.MediaCustomProxyURL())
@@ -290,7 +290,7 @@ func TestAbsoluteProxyFilterWithHttpsAlwaysAndCustomProxyServer(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_CUSTOM_URL", "https://proxy-example/proxy")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -313,46 +313,8 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_MODE", "invalid")
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
-	var err error
-	parser := config.NewParser()
-	config.Opts, err = parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	r := mux.NewRouter()
-	r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
-
-	input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
-	output := RewriteDocumentWithRelativeProxyURL(r, input)
-	expected := `<p><img src="/proxy/okK5PsdNY8F082UMQEAbLPeUFfbe2WnNfInNmR9T4WA=/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
-
-	if expected != output {
-		t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
-	}
-}
-
-func TestProxyFilterWithHttpsInvalid(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MEDIA_PROXY_MODE", "invalid")
-	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
-
-	var err error
-	parser := config.NewParser()
-	config.Opts, err = parser.ParseEnvironmentVariables()
-	if err != nil {
-		t.Fatalf(`Parsing failure: %v`, err)
-	}
-
-	r := mux.NewRouter()
-	r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
-
-	input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
-	output := RewriteDocumentWithRelativeProxyURL(r, input)
-	expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
-
-	if expected != output {
-		t.Errorf(`Not expected output: got %q instead of %q`, output, expected)
+	if _, err := config.NewConfigParser().ParseEnvironmentVariables(); err == nil {
+		t.Fatalf(`Parsing should have failed (MEDIA_PROXY_MODE=%q): %q`, os.Getenv("MEDIA_PROXY_MODE"), config.Opts.MediaProxyMode())
 	}
 }
 
@@ -363,7 +325,7 @@ func TestProxyFilterWithSrcset(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -388,7 +350,7 @@ func TestProxyFilterWithEmptySrcset(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -413,7 +375,7 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -433,12 +395,12 @@ func TestProxyFilterWithPictureSource(t *testing.T) {
 
 func TestProxyFilterOnlyNonHTTPWithPictureSource(t *testing.T) {
 	os.Clearenv()
-	os.Setenv("MEDIA_PROXY_MODE", "https")
+	os.Setenv("MEDIA_PROXY_MODE", "http-only")
 	os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -462,7 +424,7 @@ func TestProxyWithImageDataURL(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -486,7 +448,7 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_RESOURCE_TYPES", "image")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -511,7 +473,7 @@ func TestProxyFilterWithVideo(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -536,7 +498,7 @@ func TestProxyFilterVideoPoster(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -561,7 +523,7 @@ func TestProxyFilterVideoPosterOnce(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)

+ 3 - 3
internal/model/enclosure_test.go

@@ -290,7 +290,7 @@ func TestEnclosure_ProxifyEnclosureURL(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Config parsing failure: %v`, err)
@@ -420,7 +420,7 @@ func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Config parsing failure: %v`, err)
@@ -535,7 +535,7 @@ func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {
 	os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Config parsing failure: %v`, err)

+ 11 - 11
internal/model/feed_test.go

@@ -80,7 +80,7 @@ func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
 	os.Clearenv()
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -102,7 +102,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *test
 	os.Clearenv()
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -125,7 +125,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *test
 	os.Clearenv()
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -148,7 +148,7 @@ func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMaxInterval(t *test
 	os.Clearenv()
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -174,7 +174,7 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
 	os.Setenv("SCHEDULER_ROUND_ROBIN_MIN_INTERVAL", strconv.Itoa(minInterval))
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -201,7 +201,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
 	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -230,7 +230,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
 	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -259,7 +259,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
 	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -286,7 +286,7 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
 	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_FACTOR", strconv.Itoa(factor))
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -315,7 +315,7 @@ func TestFeedScheduleNextCheckEntryFrequencySmallNewTTL(t *testing.T) {
 	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
@@ -351,7 +351,7 @@ func TestFeedScheduleNextCheckEntryFrequencyLargeNewTTL(t *testing.T) {
 	os.Setenv("SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL", strconv.Itoa(minInterval))
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)

+ 2 - 2
internal/reader/filter/filter_test.go

@@ -414,7 +414,7 @@ func TestKeeplistRulesBehavior(t *testing.T) {
 // Tests for isBlockedGlobally function
 func TestIsBlockedGlobally(t *testing.T) {
 	var err error
-	config.Opts, err = config.NewParser().ParseEnvironmentVariables()
+	config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
 	}
@@ -429,7 +429,7 @@ func TestIsBlockedGlobally(t *testing.T) {
 	os.Setenv("FILTER_ENTRY_MAX_AGE_DAYS", "30")
 	defer os.Clearenv()
 
-	config.Opts, err = config.NewParser().ParseEnvironmentVariables()
+	config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
 	}

+ 3 - 3
internal/reader/processor/youtube.go

@@ -31,11 +31,11 @@ func getVideoIDFromYouTubeURL(websiteURL string) string {
 }
 
 func shouldFetchYouTubeWatchTimeForSingleEntry(entry *model.Entry) bool {
-	return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() == "" && isYouTubeVideoURL(entry.URL)
+	return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() == "" && isYouTubeVideoURL(entry.URL)
 }
 
 func shouldFetchYouTubeWatchTimeInBulk() bool {
-	return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() != ""
+	return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeAPIKey() != ""
 }
 
 func fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {
@@ -82,7 +82,7 @@ func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Dura
 
 	apiQuery := url.Values{}
 	apiQuery.Set("id", strings.Join(videoIDs, ","))
-	apiQuery.Set("key", config.Opts.YouTubeApiKey())
+	apiQuery.Set("key", config.Opts.YouTubeAPIKey())
 	apiQuery.Set("part", "contentDetails")
 
 	apiURL := url.URL{

+ 8 - 8
internal/reader/rewrite/content_rewrite_test.go

@@ -67,7 +67,7 @@ func TestRewriteWithNoMatchingRule(t *testing.T) {
 }
 
 func TestRewriteYoutubeVideoLink(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 
 	controlEntry := &model.Entry{
 		URL:     "https://www.youtube.com/watch?v=1234",
@@ -87,7 +87,7 @@ func TestRewriteYoutubeVideoLink(t *testing.T) {
 }
 
 func TestRewriteYoutubeShortLink(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 
 	controlEntry := &model.Entry{
 		URL:     "https://www.youtube.com/shorts/1LUWKWZkPjo",
@@ -107,7 +107,7 @@ func TestRewriteYoutubeShortLink(t *testing.T) {
 }
 
 func TestRewriteIncorrectYoutubeLink(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 
 	controlEntry := &model.Entry{
 		URL:     "https://www.youtube.com/some-page",
@@ -131,7 +131,7 @@ func TestRewriteYoutubeLinkAndCustomEmbedURL(t *testing.T) {
 	os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 
 	if err != nil {
@@ -156,7 +156,7 @@ func TestRewriteYoutubeLinkAndCustomEmbedURL(t *testing.T) {
 }
 
 func TestRewriteYoutubeVideoLinkUsingInvidious(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 	controlEntry := &model.Entry{
 		URL:     "https://www.youtube.com/watch?v=1234",
 		Title:   `A title`,
@@ -176,7 +176,7 @@ func TestRewriteYoutubeVideoLinkUsingInvidious(t *testing.T) {
 }
 
 func TestRewriteYoutubeShortLinkUsingInvidious(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 	controlEntry := &model.Entry{
 		URL:     "https://www.youtube.com/shorts/1LUWKWZkPjo",
 		Title:   `A title`,
@@ -196,7 +196,7 @@ func TestRewriteYoutubeShortLinkUsingInvidious(t *testing.T) {
 }
 
 func TestAddYoutubeVideoFromId(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 
 	scenarios := map[string]string{
 		// Test with single YouTube ID
@@ -239,7 +239,7 @@ func TestAddYoutubeVideoFromIdWithCustomEmbedURL(t *testing.T) {
 	os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
 
 	var err error
-	parser := config.NewParser()
+	parser := config.NewConfigParser()
 	config.Opts, err = parser.ParseEnvironmentVariables()
 
 	if err != nil {

+ 6 - 6
internal/reader/sanitizer/sanitizer_test.go

@@ -392,7 +392,7 @@ func TestInvalidNestedTag(t *testing.T) {
 }
 
 func TestInvalidIFrame(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 
 	input := `<iframe src="http://example.org/"></iframe>`
 	expected := ``
@@ -404,7 +404,7 @@ func TestInvalidIFrame(t *testing.T) {
 }
 
 func TestSameDomainIFrame(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 
 	input := `<iframe src="http://example.com/test"></iframe>`
 	expected := ``
@@ -416,7 +416,7 @@ func TestSameDomainIFrame(t *testing.T) {
 }
 
 func TestInvidiousIFrame(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 
 	input := `<iframe src="https://yewtu.be/watch?v=video_id"></iframe>`
 	expected := `<iframe src="https://yewtu.be/watch?v=video_id" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
@@ -432,7 +432,7 @@ func TestCustomYoutubeEmbedURL(t *testing.T) {
 
 	defer os.Clearenv()
 	var err error
-	if config.Opts, err = config.NewParser().ParseEnvironmentVariables(); err != nil {
+	if config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables(); err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
@@ -446,7 +446,7 @@ func TestCustomYoutubeEmbedURL(t *testing.T) {
 }
 
 func TestIFrameWithChildElements(t *testing.T) {
-	config.Opts = config.NewOptions()
+	config.Opts = config.NewConfigOptions()
 
 	input := `<iframe src="https://www.youtube.com/"><p>test</p></iframe>`
 	expected := `<iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" loading="lazy"></iframe>`
@@ -850,7 +850,7 @@ func TestReplaceYoutubeURLWithCustomURL(t *testing.T) {
 	os.Setenv("YOUTUBE_EMBED_URL_OVERRIDE", "https://invidious.custom/embed/")
 
 	var err error
-	config.Opts, err = config.NewParser().ParseEnvironmentVariables()
+	config.Opts, err = config.NewConfigParser().ParseEnvironmentVariables()
 	if err != nil {
 		t.Fatalf(`Parsing failure: %v`, err)
 	}

+ 1 - 1
internal/template/functions.go

@@ -41,7 +41,7 @@ func (f *funcMap) Map() template.FuncMap {
 		"baseURL":          config.Opts.BaseURL,
 		"rootURL":          config.Opts.RootURL,
 		"disableLocalAuth": config.Opts.DisableLocalAuth,
-		"oidcProviderName": config.Opts.OIDCProviderName,
+		"oidcProviderName": config.Opts.OAuth2OIDCProviderName,
 		"hasOAuth2Provider": func(provider string) bool {
 			return config.Opts.OAuth2Provider() == provider
 		},

+ 1 - 1
internal/ui/about.go

@@ -33,7 +33,7 @@ func (h *handler) showAboutPage(w http.ResponseWriter, r *http.Request) {
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
 	view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
-	view.Set("globalConfigOptions", config.Opts.SortedOptions(true))
+	view.Set("globalConfigOptions", config.Opts.ConfigMap(true))
 	view.Set("postgres_version", h.store.DatabaseVersion())
 	view.Set("go_version", runtime.Version())
 

+ 1 - 1
internal/ui/login_check.go

@@ -89,7 +89,7 @@ func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
 	http.SetCookie(w, cookie.New(
 		cookie.CookieUserSessionID,
 		sessionToken,
-		config.Opts.HTTPS,
+		config.Opts.HTTPS(),
 		config.Opts.BasePath(),
 	))
 

+ 1 - 1
internal/ui/logout.go

@@ -32,7 +32,7 @@ func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
 
 	http.SetCookie(w, cookie.Expired(
 		cookie.CookieUserSessionID,
-		config.Opts.HTTPS,
+		config.Opts.HTTPS(),
 		config.Opts.BasePath(),
 	))
 

+ 2 - 2
internal/ui/middleware.go

@@ -93,7 +93,7 @@ func (m *middleware) handleAppSession(next http.Handler) http.Handler {
 				}
 			}
 
-			http.SetCookie(w, cookie.New(cookie.CookieAppSessionID, session.ID, config.Opts.HTTPS, config.Opts.BasePath()))
+			http.SetCookie(w, cookie.New(cookie.CookieAppSessionID, session.ID, config.Opts.HTTPS(), config.Opts.BasePath()))
 		}
 
 		if r.Method == http.MethodPost {
@@ -261,7 +261,7 @@ func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
 		http.SetCookie(w, cookie.New(
 			cookie.CookieUserSessionID,
 			sessionToken,
-			config.Opts.HTTPS,
+			config.Opts.HTTPS(),
 			config.Opts.BasePath(),
 		))
 

+ 1 - 1
internal/ui/oauth2.go

@@ -16,6 +16,6 @@ func getOAuth2Manager(ctx context.Context) *oauth2.Manager {
 		config.Opts.OAuth2ClientID(),
 		config.Opts.OAuth2ClientSecret(),
 		config.Opts.OAuth2RedirectURL(),
-		config.Opts.OIDCDiscoveryEndpoint(),
+		config.Opts.OAuth2OIDCDiscoveryEndpoint(),
 	)
 }

+ 1 - 1
internal/ui/oauth2_callback.go

@@ -145,7 +145,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 	http.SetCookie(w, cookie.New(
 		cookie.CookieUserSessionID,
 		sessionToken,
-		config.Opts.HTTPS,
+		config.Opts.HTTPS(),
 		config.Opts.BasePath(),
 	))
 

+ 1 - 1
internal/ui/webauthn.go

@@ -331,7 +331,7 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
 	http.SetCookie(w, cookie.New(
 		cookie.CookieUserSessionID,
 		sessionToken,
-		config.Opts.HTTPS,
+		config.Opts.HTTPS(),
 		config.Opts.BasePath(),
 	))
 

+ 3 - 3
miniflux.1

@@ -329,7 +329,7 @@ Default is 300 seconds\&.
 .B HTTPS
 Forces cookies to use secure flag and send HSTS header\&.
 .br
-Default is empty\&.
+Default is disabled\&.
 .TP
 .B INVIDIOUS_INSTANCE
 Set a custom invidious instance to use\&.
@@ -466,7 +466,7 @@ Default is empty\&.
 .B OAUTH2_OIDC_PROVIDER_NAME
 Name to display for the OIDC provider\&.
 .br
-Default is OpenID Connect\&.
+Default is "OpenID Connect"\&.
 .TP
 .B OAUTH2_PROVIDER
 Possible values are "google" or "oidc"\&.
@@ -537,7 +537,7 @@ Default is 1\&.
 .B SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL
 Maximum interval in minutes for the entry frequency scheduler\&.
 .br
-Default is 24 hours\&.
+Default is 1440 minutes (24 hours)\&.
 .TP
 .B SCHEDULER_ENTRY_FREQUENCY_MIN_INTERVAL
 Minimum interval in minutes for the entry frequency scheduler\&.