package config import ( "encoding/json" "fmt" "os" "path/filepath" "runtime" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" ) type Config struct { LastCheck string `json:"last_check"` AutoUpdate bool `json:"auto_update"` InstalledApps []InstalledApp `json:"installed_apps"` ManagerInPATH bool `json:"manager_in_path"` ManagerBinPath string `json:"manager_bin_path"` InstallPath string `json:"install_path"` MPVConfigPath string `json:"mpv_config_path"` UOSCVersion string `json:"uosc_version"` ModernZVersion string `json:"modernz_version"` FFmpegVersion string `json:"ffmpeg_version"` CurrentUIType string `json:"current_ui_type"` CreateManagerShortcut bool `json:"create_manager_shortcut"` HWADecoderPreference string `json:"hwa_decoder_preference"` } type InstalledApp struct { AppName string `json:"app_name"` AppType string `json:"app_type"` InstallMethod string `json:"install_method"` InstallPath string `json:"install_path"` InstalledDate string `json:"installed_date"` AppVersion string `json:"app_version"` UpdateAvailable bool `json:"update_available"` UIType string `json:"ui_type,omitempty"` // "uosc", "modernz", or empty Managed bool `json:"managed"` // true if installed/configured by MPV Manager, false if externally detected } var globalConfig *Config func Load() (*Config, error) { if globalConfig != nil { return globalConfig, nil } configPath := getDefaultConfigPath() config := &Config{ AutoUpdate: false, InstalledApps: []InstalledApp{}, } fileExists := false if _, err := os.Stat(configPath); err == nil { fileExists = true data, err := os.ReadFile(configPath) if err == nil { if err := json.Unmarshal(data, config); err == nil { // Ensure InstalledApps is initialized (in case old config files don't have it) if config.InstalledApps == nil { config.InstalledApps = []InstalledApp{} } // Migrate apps without "managed" field - they were installed by MPV Manager // before this field existed, so they should be marked as managed needsSave := migrateManagedField(config, data) globalConfig = config // Save if we made any migrations if needsSave { if err := Save(); err != nil { log.Warn(fmt.Sprintf("Failed to save config after migration: %s", err)) } else { log.Info("Migrated installed apps: set managed=true for pre-existing entries") } } return config, nil } else { log.Error(fmt.Sprintf("Failed to parse config file: %v", err)) } } } // Auto-save default config if file doesn't exist if !fileExists || globalConfig == nil { globalConfig = config // Create config directory configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, 0755); err != nil { log.Info(fmt.Sprintf("Failed to create config directory: %s", err)) } // Save default config to file data, err := json.MarshalIndent(config, "", " ") if err == nil { if err := os.WriteFile(configPath, data, 0644); err != nil { log.Info(fmt.Sprintf("Failed to create config file: %s", err)) } else { log.Info(fmt.Sprintf("Created default config file: %s", configPath)) } } else { log.Info(fmt.Sprintf("Failed to marshal config: %s", err)) } } if globalConfig == nil { globalConfig = config } return globalConfig, nil } // migrateManagedField checks for apps without the "managed" field in the raw JSON // and sets Managed=true for them. This handles migration from older versions. // Returns true if any changes were made. func migrateManagedField(config *Config, rawData []byte) bool { // Parse raw JSON to check which apps have "managed" field var rawConfig struct { InstalledApps []map[string]interface{} `json:"installed_apps"` } if err := json.Unmarshal(rawData, &rawConfig); err != nil { log.Debug(fmt.Sprintf("Could not parse raw config for migration: %v", err)) return false } if len(rawConfig.InstalledApps) == 0 { return false } // Build a set of app install methods that have the "managed" field appsWithManagedField := make(map[string]bool) for _, rawApp := range rawConfig.InstalledApps { if methodID, ok := rawApp["install_method"].(string); ok { _, hasManaged := rawApp["managed"] if hasManaged { appsWithManagedField[methodID] = true } } } // Update apps that don't have the "managed" field changed := false for i := range config.InstalledApps { // If this app didn't have "managed" field in JSON, it's from an older version // and was installed by MPV Manager, so set Managed=true if !appsWithManagedField[config.InstalledApps[i].InstallMethod] { if !config.InstalledApps[i].Managed { config.InstalledApps[i].Managed = true changed = true log.Info(fmt.Sprintf("Migrating app %s: setting managed=true (pre-existing install)", config.InstalledApps[i].InstallMethod)) } } } return changed } func Reload() (*Config, error) { configPath := getDefaultConfigPath() config := &Config{AutoUpdate: false} data, err := os.ReadFile(configPath) if err != nil { log.Info(fmt.Sprintf("Config file not found, using defaults: %s", configPath)) globalConfig = config return config, nil } if err := json.Unmarshal(data, config); err != nil { log.Error(fmt.Sprintf("Failed to parse config file: %v", err)) globalConfig = config return config, nil } // Apply migration for managed field migrateManagedField(config, data) globalConfig = config log.Info(fmt.Sprintf("Config reloaded from: %s", configPath)) return config, nil } func Save() error { if globalConfig == nil { return fmt.Errorf("config not loaded") } configPath := getConfigPath() configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, 0755); err != nil { log.Error(fmt.Sprintf("Failed to create config directory: %s", err)) return err } data, err := json.MarshalIndent(globalConfig, "", " ") if err != nil { log.Error(fmt.Sprintf("Failed to marshal config: %s", err)) return err } if err := os.WriteFile(configPath, data, 0644); err != nil { log.Error(fmt.Sprintf("Failed to write config file: %s", err)) return err } log.Info(fmt.Sprintf("Config file saved to: %s", configPath)) return nil } func SetInstallPath(path string) { if globalConfig == nil { Load() } if path != "" { globalConfig.InstallPath = path log.Info(fmt.Sprintf("Setting install_path: %s", path)) if err := Save(); err != nil { log.Warn(fmt.Sprintf("Failed to save config after setting install_path: %s", err.Error())) } } } func GetInstallPath() string { if globalConfig == nil { Load() } if globalConfig.InstallPath != "" { return globalConfig.InstallPath } return getDefaultInstallPath() } func SetUOSCVersion(version string) { if globalConfig == nil { Load() } globalConfig.UOSCVersion = version } func GetUOSCVersion() string { if globalConfig == nil { Load() } return globalConfig.UOSCVersion } func SetModernZVersion(version string) error { if globalConfig == nil { Load() } globalConfig.ModernZVersion = version return Save() } func GetModernZVersion() string { if globalConfig == nil { Load() } return globalConfig.ModernZVersion } func SetFFmpegVersion(version string) error { if globalConfig == nil { Load() } globalConfig.FFmpegVersion = version return Save() } func GetFFmpegVersion() string { if globalConfig == nil { Load() } return globalConfig.FFmpegVersion } func SetCurrentUIType(uiType string) error { if globalConfig == nil { Load() } globalConfig.CurrentUIType = uiType return Save() } func GetCurrentUIType() string { if globalConfig == nil { Load() } if globalConfig.CurrentUIType == "" { return constants.DefaultUIType } return globalConfig.CurrentUIType } func getConfigPath() string { // On Windows, use mpv config directory if runtime.GOOS == "windows" { installPath := GetInstallPath() if installPath != "" { return filepath.Join(installPath, "portable_config", constants.ConfigFileName) } } // On Linux/macOS, use constants helper (respects XDG on Linux) path, err := constants.GetInstallerConfigFilePathWithHome() if err != nil { return filepath.Join(".", fmt.Sprintf(".%s", constants.ConfigFileName)) } return path } func getDefaultConfigPath() string { // On Windows, default to portable config location in detected MPV directory if runtime.GOOS == "windows" { return filepath.Join(constants.GetWindowsMPVBaseDir(), "portable_config", constants.ConfigFileName) } // On Linux/macOS, use constants helper (respects XDG on Linux) path, err := constants.GetInstallerConfigFilePathWithHome() if err != nil { return filepath.Join(".", constants.ConfigFileName) } return path } func getDefaultInstallPath() string { // On Windows, use detected MPV directory (AppData for fresh installs, legacy for existing) if runtime.GOOS == "windows" { return constants.GetWindowsMPVBaseDir() } // On Linux/macOS, use home directory homeDir, err := os.UserHomeDir() if err != nil { return filepath.Join(".", "mpv") } return filepath.Join(homeDir, "mpv") } func GetMPVConfigPath() string { if globalConfig == nil { Load() } if globalConfig.MPVConfigPath == "" { // Use constants helper which respects XDG on Linux path, err := constants.GetMPVConfigPathWithHome() if err != nil { // Fallback to default homeDir, _ := os.UserHomeDir() globalConfig.MPVConfigPath = filepath.Join(homeDir, ".config", "mpv") } else { // Get the directory, not the file globalConfig.MPVConfigPath = filepath.Dir(path) } } return globalConfig.MPVConfigPath } func GetPortableConfigPath(mpvDir string) string { return filepath.Join(mpvDir, "portable_config") } func GetConfigDir() string { // Use constants helper which respects XDG on Linux homeDir, err := os.UserHomeDir() if err != nil { return "." } return constants.GetInstallerConfigPath(homeDir) } func SetManagerInPATH(inPath bool) error { if globalConfig == nil { Load() } log.Info(fmt.Sprintf("Setting manager_in_path: %v", inPath)) globalConfig.ManagerInPATH = inPath if err := Save(); err != nil { log.Warn(fmt.Sprintf("Failed to save config after setting manager_in_path: %s", err.Error())) return err } return nil } func GetManagerInPATH() bool { if globalConfig == nil { Load() } return globalConfig.ManagerInPATH } func SetManagerBinPath(path string) error { if globalConfig == nil { Load() } if path == "" { log.Info("Setting manager_bin_path: (empty)") } else { log.Info(fmt.Sprintf("Setting manager_bin_path: %s", path)) } globalConfig.ManagerBinPath = path if err := Save(); err != nil { log.Warn(fmt.Sprintf("Failed to save config after setting manager_bin_path: %s", err.Error())) return err } return nil } func GetManagerBinPath() string { if globalConfig == nil { Load() } return globalConfig.ManagerBinPath } func AddInstalledApp(app InstalledApp) error { if globalConfig == nil { Load() } log.Info(fmt.Sprintf("Adding app to installed_apps: %s", app.AppName)) log.Info(fmt.Sprintf("App type: %s, Method: %s", app.AppType, app.InstallMethod)) globalConfig.InstalledApps = removeApp(globalConfig.InstalledApps, app.AppName) globalConfig.InstalledApps = append(globalConfig.InstalledApps, app) if err := Save(); err != nil { log.Error(fmt.Sprintf("Failed to save installed app %s: %s", app.AppName, err.Error())) return fmt.Errorf("failed to save installed app %s: %w", app.AppName, err) } return nil } func RemoveInstalledApp(appName string) error { if globalConfig == nil { Load() } log.Info(fmt.Sprintf("Removing app from installed_apps: %s", appName)) globalConfig.InstalledApps = removeApp(globalConfig.InstalledApps, appName) if err := Save(); err != nil { log.Error(fmt.Sprintf("Failed to remove installed app %s: %s", appName, err.Error())) return fmt.Errorf("failed to remove installed app %s: %w", appName, err) } return nil } func RemoveInstalledAppByType(appType string) error { if globalConfig == nil { Load() } var remaining []InstalledApp for _, app := range globalConfig.InstalledApps { if app.AppType != appType { remaining = append(remaining, app) } } globalConfig.InstalledApps = remaining return Save() } // RemoveInstalledAppByMethod removes an installed app by its install method ID // This is useful for cleaning up stale apps that were uninstalled externally func RemoveInstalledAppByMethod(installMethod string) error { if globalConfig == nil { Load() } var remaining []InstalledApp for _, app := range globalConfig.InstalledApps { if app.InstallMethod != installMethod { remaining = append(remaining, app) } } globalConfig.InstalledApps = remaining return Save() } func GetInstalledApps() []InstalledApp { if globalConfig == nil { Load() } return globalConfig.InstalledApps } func IsAppInstalled(appName string) bool { apps := GetInstalledApps() for _, app := range apps { if app.AppName == appName { return true } } return false } func removeApp(apps []InstalledApp, appName string) []InstalledApp { for i, app := range apps { if app.AppName == appName { return append(apps[:i], apps[i+1:]...) } } return apps } // GetCreateManagerShortcut returns whether to create desktop shortcuts func GetCreateManagerShortcut() bool { if globalConfig == nil { Load() } if globalConfig == nil { return false } return globalConfig.CreateManagerShortcut } // SetCreateManagerShortcut sets whether to create desktop shortcuts func SetCreateManagerShortcut(create bool) error { if globalConfig == nil { return fmt.Errorf("config not loaded") } globalConfig.CreateManagerShortcut = create return Save() } // SetAudioLanguagePrefs sets the audio language preferences in mpv.conf func SetAudioLanguagePrefs(langs []string) error { return SetConfigValue(constants.ConfigKeyAudioLanguage, langs, true) } // GetAudioLanguagePrefs gets the audio language preferences from mpv.conf func GetAudioLanguagePrefs() []string { return GetConfigValue(constants.ConfigKeyAudioLanguage) } // SetSubtitleLanguagePrefs sets the subtitle language preferences in mpv.conf func SetSubtitleLanguagePrefs(langs []string) error { return SetConfigValue(constants.ConfigKeySubtitleLanguage, langs, true) } // GetSubtitleLanguagePrefs gets the subtitle language preferences from mpv.conf func GetSubtitleLanguagePrefs() []string { return GetConfigValue(constants.ConfigKeySubtitleLanguage) } // UpdateInstalledAppUIType updates the UI type for an installed app // If the app is not found, it returns an error func UpdateInstalledAppUIType(appName string, uiType string) error { if globalConfig == nil { Load() } for i, app := range globalConfig.InstalledApps { if app.AppName == appName { globalConfig.InstalledApps[i].UIType = uiType if err := Save(); err != nil { log.Error(fmt.Sprintf("Failed to save app UI type %s: %s", appName, err.Error())) return fmt.Errorf("failed to save app UI type %s: %w", appName, err) } log.Info(fmt.Sprintf("Updated UI type for %s to %s", appName, uiType)) return nil } } return fmt.Errorf("app not found: %s", appName) } // UpdateInstalledAppManaged updates the managed status and UI type for an installed app // Used when adopting an externally installed app // If the app is not found, it returns an error func UpdateInstalledAppManaged(appName string, installMethod string, managed bool, uiType string) error { if globalConfig == nil { Load() } for i, app := range globalConfig.InstalledApps { if app.AppName == appName && app.InstallMethod == installMethod { globalConfig.InstalledApps[i].Managed = managed globalConfig.InstalledApps[i].UIType = uiType if err := Save(); err != nil { log.Error(fmt.Sprintf("Failed to update app %s: %s", appName, err.Error())) return fmt.Errorf("failed to update app %s: %w", appName, err) } log.Info(fmt.Sprintf("Updated app %s: managed=%v, uiType=%s", appName, managed, uiType)) return nil } } return fmt.Errorf("app not found: %s (%s)", appName, installMethod) } // GetHWADecoderPreference returns the stored hardware decoder preference // Returns empty string if no preference is stored func GetHWADecoderPreference() string { if globalConfig == nil { Load() } return globalConfig.HWADecoderPreference } // SetHWADecoderPreference saves the hardware decoder preference to the manager config // This is stored separately from mpv.conf for backup/restore purposes func SetHWADecoderPreference(hwdec string) error { if globalConfig == nil { Load() } globalConfig.HWADecoderPreference = hwdec if err := Save(); err != nil { log.Error(fmt.Sprintf("Failed to save HWA decoder preference: %s", err.Error())) return fmt.Errorf("failed to save HWA decoder preference: %w", err) } log.Info(fmt.Sprintf("Saved HWA decoder preference: %s", hwdec)) return nil } // ResetToDefaults resets the config file to default values // It creates a backup of the existing config before resetting func ResetToDefaults() error { configPath := getDefaultConfigPath() // Create a backup before reset if file exists if _, err := os.Stat(configPath); err == nil { backupPath := configPath + ".backup." + "20060102-150405" // Use current time for backup backupPath = configPath + ".backup" data, err := os.ReadFile(configPath) if err == nil { // Just create a simple backup _ = os.WriteFile(backupPath, data, 0644) log.Info("Created backup of config at: " + backupPath) } } // Reset global config to nil to force re-initialization globalConfig = nil // Create new default config newConfig := &Config{ AutoUpdate: false, InstalledApps: []InstalledApp{}, } // Save the new default config configDir := filepath.Dir(configPath) if err := os.MkdirAll(configDir, 0755); err != nil { log.Error(fmt.Sprintf("Failed to create config directory: %s", err)) return fmt.Errorf("failed to create config directory: %w", err) } data, err := json.MarshalIndent(newConfig, "", " ") if err != nil { log.Error(fmt.Sprintf("Failed to marshal default config: %s", err)) return fmt.Errorf("failed to marshal default config: %w", err) } if err := os.WriteFile(configPath, data, 0644); err != nil { log.Error(fmt.Sprintf("Failed to write default config: %s", err)) return fmt.Errorf("failed to write default config: %w", err) } // Set global config to the new default globalConfig = newConfig log.Info("Config reset to defaults successfully") return nil }