Configuration Management
This document is based on the following source files:
internal/services/config_service.go-- Core ConfigService implementationinternal/database/config_repository.go-- Config repository layerinternal/handlers/config.go-- Generic KV config endpointinternal/handlers/scan.go-- Scan-related settings endpointsinternal/handlers/hls.go-- HLS proxy settings endpointinternal/handlers/log.go-- Log-level settings endpointinternal/handlers/tab_config_setting.go-- Bottom-bar tab config endpointinternal/handlers/jsplugin_registry.go-- Plugin registry and HTTP proxy settings endpointsinternal/handlers/cache.go-- Cache management module aggregate endpointsinternal/app/routers.go-- Route registration and config change callbacksinternal/models/models.go-- Config data model
Table of Contents
- Data Model and Storage
- ConfigService Design
- Three Configuration Interface Systems
- Business Endpoint List (/settings/*)
- Configuration Change Callback Mechanism
- Tab Configuration Details
- Module Aggregate Endpoint Example: /cache-manage/config
- 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:
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:
| Method | Read/Write | Description |
|---|---|---|
GetString(key, default) | Read | Returns the raw string, or the default value when missing |
GetInt(key, default) | Read | strconv.Atoi conversion, returns default on failure |
GetBool(key, default) | Read | strconv.ParseBool conversion, returns default on failure |
GetJSON(key, target) | Read | json.Unmarshal into any struct, auto-clears the cache when it is invalid |
Set(key, value) | Write | Writes the string value and updates the cache |
SetJSON(key, value) | Write | Calls 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 theonConfigChangedcallback. 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
| Field | Type | Default | Description |
|---|---|---|---|
path | string | "music" | Music root directory path |
exclude_dirs | string[] | ["@eaDir", "tmp"] | Exclude by directory name (recursive match) |
exclude_paths | string[] | [] | Exclude by full path |
- config key:
music_path(JSON value) - handler:
ScanHandler - Side effect: after PUT, asynchronously calls the
onMusicPathChangedcallback, rebuilding the Scanner and clearing songs in excluded directories - Validation:
pathcannot be empty
4.2 /settings/hls-proxy
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | HLS 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; whentrue, the server fetches and rewrites the m3u8 and proxies all segments
4.3 /settings/auto-scan
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Auto-scan toggle |
interval_seconds | int | 3600 | Scan interval (seconds), range [60, 86400] |
- config key:
auto_scan(JSON value) - handler:
ScanHandler - Side effect: after PUT, asynchronously calls the
onAutoScanChangedcallback, restarting the scheduler viaautoScanner.ApplyConfig - Validation:
interval_secondsmust be between 60 and 86400
4.4 /settings/scan-title-source
| Field | Type | Default | Description |
|---|---|---|---|
title_source | string | "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
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether to auto-create playlists after a scan |
- config key:
scan_auto_create_playlists(string"true"/"false") - handler:
ScanHandler
4.6 /settings/scan-playlist-mode
| Field | Type | Default | Description |
|---|---|---|---|
mode | string | "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 playlisttop_level: merge only by top-level (first-level) directory; all songs under the same top-level directory go into the same playlistbubble_up: a song joins the playlist of its own directory while also bubbling up to join the playlists corresponding to all its parent directoriesconfig key:
scan_playlist_mode(string value)handler:
ScanHandlerValidation:
modemust be one ofdirectory/top_level/bubble_up, otherwise it returns 400
This mode takes effect only when §4.5
scan-auto-create-playlistsis enabled (defaulttrue); the two together decide "whether to create directory playlists" and "how to merge directories".
4.7 /settings/log-level
| Field | Type | Default | Description |
|---|---|---|---|
level | string | "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
ParseLogLevelmapping table; an invalid level returns 400
4.8 /settings/plugin-registries
| Field | Type | Default | Description |
|---|---|---|---|
registries | RegistryConfig[] | Official plugin registry | List 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
| Field | Type | Default | Description |
|---|---|---|---|
proxy | string | "" | 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:
SetGlobalProxyinternally validates the proxy address format
4.10 /settings/tab-config
| Field | Type | Default | Description |
|---|---|---|---|
show_library | bool | true | Whether to show the Library tab |
show_playlists | bool | true | Whether to show the Playlists tab |
plugin_tabs | pluginTabEntry[] | [] | List of plugin tabs |
- config key:
tab_config(JSON value) - handler:
ConfigHandler - For detailed rules see Tab Configuration Details below
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
// 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
type pluginTabEntry struct {
PluginID int `json:"plugin_id"` // 插件 ID
EntryPath string `json:"entry_path"` // 插件入口路径(不可为空,不可重复)
Name string `json:"name"` // 显示名称(不可为空)
}6.3 Validation Rules
- The total number of optional tabs (
show_libraryenabled counts as 1 +show_playlistsenabled counts as 1 + the length ofplugin_tabs) does not exceed 3 - The
entry_pathof each plugin tab cannot be empty - The
nameof each plugin tab cannot be empty - The
entry_pathvalues 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:
| Endpoint | Method | Description |
|---|---|---|
/cache-manage/stats | GET | Get cache statistics (total size, file count, max limit) |
/cache-manage/clean | POST | Clean all caches |
/cache-manage/config | GET | Get cache config (max_size, cache_dir) |
/cache-manage/config | PUT | Update cache config |
/cache-manage/validate-dir | POST | Validate 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_sizecannot be negative (0 means unlimited)- when
cache_diris an empty string, the default directory is restored - when
cache_diris 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.
