Skip to content

Configuration Management

This document is based on the following source files:

  • internal/services/config_service.go -- Core ConfigService implementation
  • internal/database/config_repository.go -- Config repository layer
  • internal/handlers/config.go -- Generic KV config endpoint
  • internal/handlers/scan.go -- Scan-related settings endpoints
  • internal/handlers/hls.go -- HLS proxy settings endpoint
  • internal/handlers/log.go -- Log-level settings endpoint
  • internal/handlers/tab_config_setting.go -- Bottom-bar tab config endpoint
  • internal/handlers/jsplugin_registry.go -- Plugin registry and HTTP proxy settings endpoints
  • internal/handlers/cache.go -- Cache management module aggregate endpoints
  • internal/app/routers.go -- Route registration and config change callbacks
  • internal/models/models.go -- Config data model

Table of Contents

  1. Data Model and Storage
  2. ConfigService Design
  3. Three Configuration Interface Systems
  4. Business Endpoint List (/settings/*)
  5. Configuration Change Callback Mechanism
  6. Tab Configuration Details
  7. Module Aggregate Endpoint Example: /cache-manage/config
  8. Interface Selection Decision Tree

1. Data Model and Storage

Section source: internal/models/models.go, internal/database/config_repository.go

All configuration is persisted in SQLite's configs table, one key-value pair per row:

go
type Config struct {
    ID        int64     `json:"id"`
    Key       string    `json:"key"`        // 唯一键,如 "music_path"、"hls_proxy_enabled"
    Value     string    `json:"value"`       // 字符串值,复杂结构以 JSON 序列化存储
    UpdatedAt time.Time `json:"updated_at"`
}

The repository layer ConfigRepository follows the project's hybrid sqlc + squirrel strategy: fixed SQL (Get/Set/Delete) goes through sqlc, while the dynamically filtered List/Count go through squirrel (supporting key LIKE keyword search + allowlist ordering + pagination). Set is implemented as an UPSERT on key, and a Get/Delete that misses returns ErrNotFound.


2. ConfigService Design

Section source: internal/services/config_service.go

ConfigService is the sole business entry point for configuration; all handlers read and write config through it. Key design points:

2.1 In-Memory Cache

A sync.Map is used as a concurrency-safe in-memory cache (key is the config key, value is the raw string value):

  • Read path: check the cache first, return directly on hit; on miss, query the database, write to the cache, then return
  • Write path: write to the database first, then on success update the cache (Set) or clear the cache (CreateConfig/UpdateConfig/DeleteConfig)
  • ClearCache() resets the entire cache; ClearCacheKey(key) clears a single key

2.2 Typed Helper Methods

ConfigService provides a set of typed read/write methods that transparently convert the string value in the configs table to the type required by the business:

MethodRead/WriteDescription
GetString(key, default)ReadReturns the raw string, or the default value when missing
GetInt(key, default)Readstrconv.Atoi conversion, returns default on failure
GetBool(key, default)Readstrconv.ParseBool conversion, returns default on failure
GetJSON(key, target)Readjson.Unmarshal into any struct, auto-clears the cache when it is invalid
Set(key, value)WriteWrites the string value and updates the cache
SetJSON(key, value)WriteCalls Set after json.Marshal

All Get* methods silently degrade on database query failure (log via slog.Warn and return the default value), without interrupting the business flow. GetJSON is the exception: it returns an error and lets the caller decide the degradation strategy.

For the generic KV endpoint there is also a full CRUD method set (GetConfig/CreateConfig/UpdateConfig/DeleteConfig/ListConfigs/CountConfigs), which passes through directly to the repository layer and clears the corresponding key's cache after write operations.


3. Three Configuration Interface Systems

Section source: internal/app/routers.go, the configuration interface specification chapter of AGENTS.md

Three configuration interfaces exist in the project, each with its own role:

┌─────────────────────────────────────────────────────────────────┐
│                Configuration Interface System                     │
├─────────────────┬──────────────────┬────────────────────────────┤
│  /settings/*    │  /module/config  │  /configs/{key}            │
│  Business EP     │  Module aggregate │  Generic KV               │
├─────────────────┼──────────────────┼────────────────────────────┤
│  Strong-typed JSON│ Strong-typed JSON│ Raw key/value string      │
│  Built-in default │  Built-in default │  key missing → 404        │
│  Inline side-effect│Inline side-effect│ No side-effect (except cb)│
│  Frontend feature │  Module-internal  │  admin editor only        │
└─────────────────┴──────────────────┴────────────────────────────┘
  • /settings/<name> (isolated business endpoint): strong-typed JSON, with built-in default values and side effects inside the handler. The path is kebab-case and belongs to the handler of the corresponding business module
  • /module/config (module aggregate endpoint): used when the config is strongly related to the module's action endpoints (existing instance: /cache-manage/config)
  • /configs/{key} (generic KV): the backend entry point for the admin config editor (config_manager.dart), providing full CRUD. PUT returns 404 when the key does not exist, has no strong-type validation, and side effects are triggered only through the onConfigChanged callback. New business features should not call this interface directly

4. Business Endpoint List (/settings/*)

Section source: internal/app/routers.go (route registration), the individual handler files

All business endpoints support both GET and PUT: GET returns the current value (including defaults), and PUT writes the value and may trigger side effects.

4.1 /settings/music-path

Diagram source: internal/handlers/scan.go MusicPathSetting struct

FieldTypeDefaultDescription
pathstring"music"Music root directory path
exclude_dirsstring[]["@eaDir", "tmp"]Exclude by directory name (recursive match)
exclude_pathsstring[][]Exclude by full path
  • config key: music_path (JSON value)
  • handler: ScanHandler
  • Side effect: after PUT, asynchronously calls the onMusicPathChanged callback, rebuilding the Scanner and clearing songs in excluded directories
  • Validation: path cannot be empty

4.2 /settings/hls-proxy

FieldTypeDefaultDescription
enabledboolfalseHLS radio reverse-proxy toggle
  • config key: hls_proxy_enabled (string "true"/"false")
  • handler: HLSHandler
  • Business semantics: when false, the radio m3u8 is 302-redirected directly to the player; when true, the server fetches and rewrites the m3u8 and proxies all segments

4.3 /settings/auto-scan

FieldTypeDefaultDescription
enabledboolfalseAuto-scan toggle
interval_secondsint3600Scan interval (seconds), range [60, 86400]
  • config key: auto_scan (JSON value)
  • handler: ScanHandler
  • Side effect: after PUT, asynchronously calls the onAutoScanChanged callback, restarting the scheduler via autoScanner.ApplyConfig
  • Validation: interval_seconds must be between 60 and 86400

4.4 /settings/scan-title-source

FieldTypeDefaultDescription
title_sourcestring"tag"Scan title source, tag or filename
  • config key: scan_title_source (string value)
  • handler: ScanHandler
  • Side effect: after PUT, triggers onMusicPathChanged (a scan in "re-import" mode is required for it to take effect)
  • Validation: value must be "tag" or "filename"

4.5 /settings/scan-auto-create-playlists

FieldTypeDefaultDescription
enabledbooltrueWhether to auto-create playlists after a scan
  • config key: scan_auto_create_playlists (string "true"/"false")
  • handler: ScanHandler

4.6 /settings/scan-playlist-mode

FieldTypeDefaultDescription
modestring"directory"Directory-playlist merge mode, one of directory / top_level / bubble_up

The semantics of the three modes (consistent with PlaylistRepository.AutoCreate):

  • directory: each folder a song resides in generates its own independent playlist

  • top_level: merge only by top-level (first-level) directory; all songs under the same top-level directory go into the same playlist

  • bubble_up: a song joins the playlist of its own directory while also bubbling up to join the playlists corresponding to all its parent directories

  • config key: scan_playlist_mode (string value)

  • handler: ScanHandler

  • Validation: mode must be one of directory / top_level / bubble_up, otherwise it returns 400

This mode takes effect only when §4.5 scan-auto-create-playlists is enabled (default true); the two together decide "whether to create directory playlists" and "how to merge directories".

4.7 /settings/log-level

FieldTypeDefaultDescription
levelstring"info"Log level: debug/info/warn/error
  • config key: log_level (string value)
  • handler: LogHandler (a standalone handler holding a *slog.LevelVar)
  • Side effect: on PUT, instantly switches the runtime log level via levelVar.Set(lvl), no restart required
  • Validation: validated against the ParseLogLevel mapping table; an invalid level returns 400

4.8 /settings/plugin-registries

FieldTypeDefaultDescription
registriesRegistryConfig[]Official plugin registryList of plugin subscription registries

Each RegistryConfig contains url (string), name (string), and enabled (bool).

  • config key: plugin_registries (JSON value)
  • handler: JSPluginHandler
  • Default value: when unconfigured, returns a single-element list containing the official Songloft plugin registry

4.9 /settings/http-proxy

FieldTypeDefaultDescription
proxystring""HTTP proxy address, e.g. http://192.168.1.1:7890
  • config key: http_proxy (JSON value)
  • handler: JSPluginHandler
  • Side effect: on PUT, calls httputil.SetGlobalProxy(proxy) to instantly switch the global HTTP proxy
  • Validation: SetGlobalProxy internally validates the proxy address format

4.10 /settings/tab-config

FieldTypeDefaultDescription
show_librarybooltrueWhether to show the Library tab
show_playlistsbooltrueWhether to show the Playlists tab
plugin_tabspluginTabEntry[][]List of plugin tabs

5. Configuration Change Callback Mechanism

Section source: internal/handlers/config.go (SetOnConfigChanged), internal/app/routers.go (callback registration)

After the generic KV endpoint PUT /configs/{key} updates a config, it triggers the onConfigChanged callback. This is the key mechanism for keeping the side effects of the generic endpoint aligned with the business endpoints.

5.1 Callback Registration

go
// routers.go 中的注册逻辑
configHandler.SetOnConfigChanged(func(key string) {
    switch key {
    case "music_path":
        musicPathChanged()       // 重建 Scanner + 清理排除歌曲
    case "auto_scan":
        cfg := a.autoScanner.GetConfig()
        a.autoScanner.ApplyConfig(cfg)  // 重启自动扫描调度
    }
})

5.2 Dual-Entry Consistency

The callback runs asynchronously via go and does not block the PUT response. When a business endpoint and the generic endpoint write the same config key, they must trigger the same side effect. For example, PUT /settings/music-path calls onMusicPathChanged() directly inside the handler, while PUT /configs/music_path triggers the same function indirectly through configHandler.onConfigChanged("music_path").


6. Tab Configuration Details

Section source: internal/handlers/tab_config_setting.go

The bottom navigation bar configuration controls the composition of the frontend App's bottom tabs, with a maximum of 5 tabs.

6.1 Tab Structure

┌───────────────────────────────────────────────────┐
│  Bottom Navigation Bar (max 5 tabs)                │
├──────────┬──────────┬──────────┬──────────┬────────┤
│  Home    │  Library │  Playlist│  PluginX │Settings│
│ (fixed)  │(optional)│(optional)│(optional)│(fixed) │
└──────────┴──────────┴──────────┴──────────┴────────┘
    Fixed        Optional (max 3)          Fixed
  • Fixed items: Home and Settings are always shown and are not part of the configuration
  • Optional items: Library (show_library), Playlists (show_playlists), and plugin tabs (plugin_tabs); the total of the three does not exceed 3

6.2 Plugin Tab Entry

go
type pluginTabEntry struct {
    PluginID  int    `json:"plugin_id"`    // 插件 ID
    EntryPath string `json:"entry_path"`   // 插件入口路径(不可为空,不可重复)
    Name      string `json:"name"`         // 显示名称(不可为空)
}

6.3 Validation Rules

  1. The total number of optional tabs (show_library enabled counts as 1 + show_playlists enabled counts as 1 + the length of plugin_tabs) does not exceed 3
  2. The entry_path of each plugin tab cannot be empty
  3. The name of each plugin tab cannot be empty
  4. The entry_path values across plugin tabs cannot be duplicated

6.4 Default Configuration

When unconfigured, the default values are returned: show_library=true, show_playlists=true, plugin_tabs=[], i.e. 4 tabs (Home, Library, Playlists, Settings).


7. Module Aggregate Endpoint Example: /cache-manage/config

Section source: internal/handlers/cache.go

The cache management module aggregates its configuration endpoint and action endpoints under the same prefix /api/v1/cache-manage/, and is a typical instance of the module aggregation pattern:

EndpointMethodDescription
/cache-manage/statsGETGet cache statistics (total size, file count, max limit)
/cache-manage/cleanPOSTClean all caches
/cache-manage/configGETGet cache config (max_size, cache_dir)
/cache-manage/configPUTUpdate cache config
/cache-manage/validate-dirPOSTValidate directory availability (auto-create + writability check + disk space)

The configuration endpoint shares the same CacheService with stats/clean, which is a strongly-coupled scenario, so it is kept under the module prefix rather than split out to /settings/.

Validation logic when PUT updates the cache config:

  • max_size cannot be negative (0 means unlimited)
  • when cache_dir is an empty string, the default directory is restored
  • when cache_dir is non-empty it must be an absolute path and pass the writability check

8. Interface Selection Decision Tree

Diagram source: a synthesis of the configuration interface specification in AGENTS.md and the individual handler implementations

When choosing an interface style for a new configuration, refer to the following decision flow:

Add a new config item

    ├── Does the config belong to a business module that already has action endpoints?
    │       │
    │       ├── Yes → Does the module already have a /module/config endpoint?
    │       │       │
    │       │       ├── Yes → Extend the existing config struct
    │       │       └── No  → Evaluate whether a new aggregate endpoint is worth it
    │       │                 otherwise use /settings/<name>
    │       │
    │       └── No → /settings/<name>

    └── For admin debugging only?

            ├── Yes → Write directly to the configs table, access via the generic KV endpoint
            └── No  → /settings/<name>

Core principle: business endpoints are the single source for user-visible entry points, and the generic KV degrades to an admin backdoor. When both entries write the same key, the side effects must be kept consistent through the SetOnConfigChanged callback.