package version import ( "bufio" "fmt" "os" "os/exec" "path/filepath" "strings" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/platform" ) type UpdateCheckResult struct { AppsToUpdate []UpdateItem NoEligibleApps bool ConfigBackupItems []ConfigBackupItem } type UpdateItem struct { AppName string CurrentVersion string AvailableVersion string UpdateType string PackageManager string MethodID string } type ConfigBackupItem struct { BackupName string BackupPath string BackupDate string } func CheckForAppUpdates(releaseInfo *installer.ReleaseInfo, installedApps []config.InstalledApp) *UpdateCheckResult { result := &UpdateCheckResult{ AppsToUpdate: []UpdateItem{}, ConfigBackupItems: []ConfigBackupItem{}, } for _, app := range installedApps { updateItem := checkAppForUpdate(app, releaseInfo) if updateItem != nil { result.AppsToUpdate = append(result.AppsToUpdate, *updateItem) } } checkUOSCUpdate(result, releaseInfo) checkModernZUpdate(result, releaseInfo) checkFFmpegUpdate(result, releaseInfo) hasManagedApp := false for _, app := range installedApps { if isManagedApp(app.InstallMethod) { hasManagedApp = true break } } result.NoEligibleApps = !hasManagedApp && len(result.AppsToUpdate) == 0 checkConfigBackups(result) return result } func isManagedApp(methodID string) bool { managedMethods := []string{ constants.MethodMPVBinary, constants.MethodMPVBinaryV3, constants.MethodMPVApp, constants.MethodMPCQT, constants.MethodIINA, } for _, m := range managedMethods { if methodID == m { return true } } return false } func checkAppForUpdate(app config.InstalledApp, releaseInfo *installer.ReleaseInfo) *UpdateItem { switch app.InstallMethod { case constants.MethodMPVBinary, constants.MethodMPVBinaryV3, constants.MethodMPVApp: currentVersion := app.AppVersion availableVersion := releaseInfo.MpvVersion updateAvailable := IsVersionUpdateAvailable(currentVersion, availableVersion) // Determine UpdateType based on whether update is available updateType := "current-version" if updateAvailable { updateType = "update" } return &UpdateItem{ AppName: app.AppName, CurrentVersion: currentVersion, AvailableVersion: availableVersion, UpdateType: updateType, MethodID: app.InstallMethod, } case constants.MethodMPCQT: currentVersion := app.AppVersion availableVersion := releaseInfo.MPCQT.AppVersion updateAvailable := IsVersionUpdateAvailable(currentVersion, availableVersion) // Determine UpdateType based on whether update is available updateType := "current-version" if updateAvailable { updateType = "update" } return &UpdateItem{ AppName: app.AppName, CurrentVersion: currentVersion, AvailableVersion: availableVersion, UpdateType: updateType, MethodID: app.InstallMethod, } case constants.MethodIINA: currentVersion := app.AppVersion availableVersion := releaseInfo.IINA.AppVersion updateAvailable := IsVersionUpdateAvailable(currentVersion, availableVersion) // Determine UpdateType based on whether update is available updateType := "current-version" if updateAvailable { updateType = "update" } return &UpdateItem{ AppName: app.AppName, CurrentVersion: currentVersion, AvailableVersion: availableVersion, UpdateType: updateType, MethodID: app.InstallMethod, } case constants.MethodMPVFlatpak, constants.MethodMPVPackage, constants.MethodCelluloidFlatpak, constants.MethodCelluloidPackage, constants.MethodMPVBrew: pkgManager := getPackageManagerName(app.InstallMethod) currentVersion := app.AppVersion // If no version is stored, try to query package manager if currentVersion == "" { currentVersion = queryPackageManagerVersion(app.InstallMethod, app.AppName) } // Always return update item for version display, even if version query failed // For package managers, show current version without update button availableVersion := "Check via " + pkgManager if currentVersion == "" { availableVersion = "Unknown" } return &UpdateItem{ AppName: app.AppName, CurrentVersion: currentVersion, AvailableVersion: availableVersion, UpdateType: "package-info", PackageManager: pkgManager, MethodID: app.InstallMethod, } return nil } return nil } func checkUOSCUpdate(result *UpdateCheckResult, releaseInfo *installer.ReleaseInfo) { installedUOSCVersion := config.GetUOSCVersion() if installedUOSCVersion == "" { return } availableUOSCVersion := releaseInfo.UOSC.AppVersion if IsVersionUpdateAvailable(installedUOSCVersion, availableUOSCVersion) { result.AppsToUpdate = append(result.AppsToUpdate, UpdateItem{ AppName: "uOSC - custom MPV UI", CurrentVersion: installedUOSCVersion, AvailableVersion: availableUOSCVersion, UpdateType: "update", MethodID: constants.MethodUOSC, }) } } func checkModernZUpdate(result *UpdateCheckResult, releaseInfo *installer.ReleaseInfo) { installedModernZVersion := config.GetModernZVersion() if installedModernZVersion == "" { return } availableModernZVersion := releaseInfo.ModernZ.AppVersion if IsVersionUpdateAvailable(installedModernZVersion, availableModernZVersion) { result.AppsToUpdate = append(result.AppsToUpdate, UpdateItem{ AppName: "ModernZ - custom MPV UI", CurrentVersion: installedModernZVersion, AvailableVersion: availableModernZVersion, UpdateType: "update", MethodID: constants.MethodModernZ, }) } } func checkFFmpegUpdate(result *UpdateCheckResult, releaseInfo *installer.ReleaseInfo) { installedFFmpegVersion := config.GetFFmpegVersion() if installedFFmpegVersion == "" { return } availableFFmpegVersion := releaseInfo.FFmpeg.AppVersion if IsVersionUpdateAvailable(installedFFmpegVersion, availableFFmpegVersion) { result.AppsToUpdate = append(result.AppsToUpdate, UpdateItem{ AppName: "FFmpeg - media utilities", CurrentVersion: installedFFmpegVersion, AvailableVersion: availableFFmpegVersion, UpdateType: "update", MethodID: constants.MethodFFmpeg, }) } } func getPackageManagerName(methodID string) string { switch methodID { case constants.MethodMPVFlatpak, constants.MethodCelluloidFlatpak: return "Flatpak" case constants.MethodMPVPackage, constants.MethodCelluloidPackage: return "Package Manager" case constants.MethodMPVBrew: return "Homebrew" } return "Unknown" } func checkConfigBackups(result *UpdateCheckResult) { configDir := config.GetMPVConfigPath() if configDir == "" { return } backupDir := configDir + "/conf_backups" backupDir = strings.ReplaceAll(backupDir, "~", "") if err := checkAndCollectBackups(backupDir, &result.ConfigBackupItems); err != nil { return } } func checkAndCollectBackups(backupDir string, items *[]ConfigBackupItem) error { files, err := os.ReadDir(backupDir) if err != nil { return err } for _, file := range files { fileName := file.Name() if strings.HasSuffix(fileName, ".conf") { backupName := strings.TrimSuffix(fileName, ".conf") backupPath := filepath.Join(backupDir, fileName) info, err := os.Stat(backupPath) if err != nil { continue } *items = append(*items, ConfigBackupItem{ BackupName: backupName, BackupPath: backupPath, BackupDate: info.ModTime().Format("2006-01-02 15:04"), }) } } return nil } func queryPackageManagerVersion(methodID, appName string) string { switch { case strings.Contains(methodID, "flatpak"): return queryFlatpakVersion(appName) case strings.Contains(methodID, "brew"): return queryBrewVersion(appName) case strings.Contains(methodID, "package"): // Detect distro and use appropriate package manager distroFamily := detectDistroFamily() switch distroFamily { case "arch": return queryPacmanVersion(appName) case "rhel": return queryDnfVersion(appName) case "suse": return queryZypperVersion(appName) default: return queryAptVersion(appName) } default: return "" } } // detectDistroFamily reads /etc/os-release and returns the distro family using platform.GetDistroFamily func detectDistroFamily() string { if _, err := os.Stat("/etc/os-release"); err != nil { return "debian" // default } file, err := os.Open("/etc/os-release") if err != nil { return "debian" } defer file.Close() var id, idLike string scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "ID=") { id = strings.Trim(strings.TrimPrefix(line, "ID="), `"`) } else if strings.HasPrefix(line, "ID_LIKE=") { idLike = strings.Trim(strings.TrimPrefix(line, "ID_LIKE="), `"`) } } return platform.GetDistroFamily(id, idLike) } func queryFlatpakVersion(appName string) string { packageName := getFlatpakPackageName(appName) log.Debug(fmt.Sprintf("Querying Flatpak version for: %s (package: %s)", appName, packageName)) cmd := exec.Command("flatpak", "info", packageName) output, err := cmd.CombinedOutput() if err != nil { log.Debug(fmt.Sprintf("Flatpak info command failed for %s: %s", packageName, err.Error())) return "" } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Version:") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) log.Debug(fmt.Sprintf("Found Flatpak version for %s: %s", appName, version)) return version } } } log.Debug("No Flatpak version found for: " + appName) return "" } func queryBrewVersion(appName string) string { packageName := getBrewPackageName(appName) log.Debug(fmt.Sprintf("Querying Brew version for: %s (package: %s)", appName, packageName)) cmd := exec.Command("brew", "list", "--versions", packageName) output, err := cmd.CombinedOutput() if err != nil { log.Debug(fmt.Sprintf("Brew list command failed for %s: %s", packageName, err.Error())) return "" } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, packageName) { parts := strings.Fields(line) if len(parts) >= 2 { version := parts[1] log.Debug(fmt.Sprintf("Found Brew version for %s: %s", appName, version)) return version } } } log.Debug("No Brew version found for: " + appName) return "" } func queryAptVersion(appName string) string { packageName := getAptPackageName(appName) log.Debug(fmt.Sprintf("Querying APT version for: %s (package: %s)", appName, packageName)) cmd := exec.Command("apt-cache", "policy", packageName) output, err := cmd.CombinedOutput() if err != nil { log.Debug(fmt.Sprintf("APT cache command failed for %s: %s", packageName, err.Error())) return "" } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Installed:") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) if version != "(none)" { log.Debug(fmt.Sprintf("Found APT version for %s: %s", appName, version)) return version } } } } log.Debug("No APT version found for: " + appName) return "" } func getFlatpakPackageName(appName string) string { appName = strings.ToLower(appName) switch appName { case "mpv": return constants.FlatpakMPV case "celluloid": return constants.FlatpakCelluloid default: return appName } } func getBrewPackageName(appName string) string { appName = strings.ToLower(appName) switch appName { case "mpv": return "mpv" default: return appName } } func getAptPackageName(appName string) string { appName = strings.ToLower(appName) switch appName { case "mpv": return "mpv" case "celluloid": return "celluloid" default: return appName } } func queryPacmanVersion(appName string) string { packageName := getPacmanPackageName(appName) log.Debug(fmt.Sprintf("Querying pacman version for: %s (package: %s)", appName, packageName)) cmd := exec.Command("pacman", "-Qi", packageName) output, err := cmd.CombinedOutput() if err != nil { log.Debug(fmt.Sprintf("pacman command failed for %s: %s", packageName, err.Error())) return "" } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Version") && strings.Contains(line, ":") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { version := strings.TrimSpace(parts[1]) // Handle version format like "1.2.3-1" if idx := strings.Index(version, " "); idx > 0 { version = version[:idx] } log.Debug(fmt.Sprintf("Found pacman version for %s: %s", appName, version)) return version } } } log.Debug("No pacman version found for: " + appName) return "" } func queryDnfVersion(appName string) string { packageName := getDnfPackageName(appName) log.Debug(fmt.Sprintf("Querying DNF version for: %s (package: %s)", appName, packageName)) cmd := exec.Command("rpm", "-q", "--queryformat", "%{VERSION}", packageName) output, err := cmd.Output() if err != nil { log.Debug(fmt.Sprintf("DNF/RPM command failed for %s: %s", packageName, err.Error())) return "" } version := strings.TrimSpace(string(output)) if version != "" { log.Debug(fmt.Sprintf("Found DNF version for %s: %s", appName, version)) return version } log.Debug("No DNF version found for: " + appName) return "" } func getPacmanPackageName(appName string) string { appName = strings.ToLower(appName) switch appName { case "mpv": return "mpv" case "celluloid": return "celluloid" default: return appName } } func getDnfPackageName(appName string) string { appName = strings.ToLower(appName) switch appName { case "mpv": return "mpv" case "celluloid": return "celluloid" default: return appName } } func queryZypperVersion(appName string) string { packageName := getZypperPackageName(appName) log.Debug(fmt.Sprintf("Querying Zypper version for: %s (package: %s)", appName, packageName)) cmd := exec.Command("rpm", "-q", "--queryformat", "%{VERSION}", packageName) output, err := cmd.Output() if err != nil { log.Debug(fmt.Sprintf("Zypper/RPM command failed for %s: %s", packageName, err.Error())) return "" } version := strings.TrimSpace(string(output)) if version != "" { log.Debug(fmt.Sprintf("Found Zypper version for %s: %s", appName, version)) return version } log.Debug("No Zypper version found for: " + appName) return "" } func getZypperPackageName(appName string) string { appName = strings.ToLower(appName) switch appName { case "mpv": return "mpv" case "celluloid": return "celluloid" default: return appName } }