package web import ( "context" "fmt" "html/template" "io/fs" "net/http" "path/filepath" "runtime" "strings" "sync" "time" "gitgud.io/mike/mpv-manager/internal/webassets" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/hotkeys" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/locale" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/platform" "gitgud.io/mike/mpv-manager/pkg/version" ) // createAppCardData creates AppCardTemplateData for use in templates func createAppCardData(app AppGroupInfo, platform *platform.Platform) AppCardTemplateData { return AppCardTemplateData{ App: app, Platform: platform, } } type Server struct { platform *platform.Platform config *config.Config installer *installer.Installer releaseInfo *installer.ReleaseInfo templates *template.Template sessions *SessionManager sessionMux sync.RWMutex Addr string // HTTP server for graceful shutdown httpServer *http.Server // Job Manager for new job-based API (installs, updates, uninstalls) jobManager *JobManager // Version check cache versionCache *VersionCache versionCacheMux sync.RWMutex } type VersionCache struct { LastChecked time.Time UpdateMap map[string]version.UpdateItem // methodID -> UpdateItem PackageVersionMap map[string]AppVersionInfo // methodID -> {current, available} } type AppVersionInfo struct { CurrentVersion string AvailableVersion string } // PackageVersionResult holds result of a package version check type PackageVersionResult struct { MethodID string CurrentVersion string AvailableVersion string Found bool // true if the package was found installed } func NewServer(p *platform.Platform, cfg *config.Config, inst *installer.Installer, releaseInfo *installer.ReleaseInfo) *Server { templatesFS, err := fs.Sub(webassets.TemplatesFS, "templates") if err != nil { log.Error("Failed to create templates filesystem: " + err.Error()) } tmpl := template.New("") tmpl = tmpl.Funcs(template.FuncMap{ "getCodecColor": getCodecColor, "formatCodecName": formatCodecName, "getCodecBadgeColor": getCodecBadgeColor, "FormatLanguageDisplay": FormatLanguageDisplay, "GetFlagDisplay": GetFlagDisplay, "IsCommonLanguage": IsCommonLanguage, "toJSON": toJSON, "toString": toString, "getVendorColor": getVendorColor, "getVendorClass": getVendorClass, "getVendorDisplay": getVendorDisplay, "isX86Architecture": isX86Architecture, "isARMArchitecture": isARMArchitecture, "getAppLogo": getAppLogo, "getPackageTypeLogo": getPackageTypeLogo, "getDistroLogo": getDistroLogo, "getHWADescription": platform.GetHwaDescription, "getHWABadgeColor": getHWABadgeColor, "getHWABadgeText": getHWABadgeText, "getGPUAPIDescription": getGPUAPIDescription, "createAppCardData": createAppCardData, "capitalize": capitalize, "capitalizeArchitecture": capitalizeArchitecture, "getOSLogo": getOSLogo, "getOSDisplayName": getOSDisplayName, "getDistroFamilyLogo": getDistroFamilyLogo, "GetLanguageEntry": GetLanguageEntry, "GetLanguageCountryCode": GetLanguageCountryCode, "GetRegionalLanguageName": GetRegionalLanguageName, "GetRegionalNativeName": GetRegionalNativeName, "cleanAppVersion": cleanAppVersion, "categoryIcon": categoryIcon, "formatKeys": formatKeys, "canChangeUI": canChangeUI, }) tmpl, err = tmpl.ParseFS(templatesFS, "*.html") if err != nil { log.Error("Failed to parse HTML templates: " + err.Error()) panic(fmt.Sprintf("Failed to parse HTML templates: %v", err)) } server := &Server{ platform: p, config: cfg, installer: inst, releaseInfo: releaseInfo, templates: tmpl, sessions: NewSessionManager(), jobManager: NewJobManager(), } // Initialize version cache at startup server.initVersionCache() return server } func (s *Server) ListenAndServe(addr string) error { s.Addr = addr staticFS, err := fs.Sub(webassets.StaticFS, "static") if err != nil { log.Error("Failed to create static filesystem: " + err.Error()) } fileServer := http.FileServer(http.FS(staticFS)) mux := http.NewServeMux() handler := securityMiddleware(corsMiddleware(mux)) mux.HandleFunc("/", s.handleHome) mux.HandleFunc("/dashboard", s.handleDashboard) mux.HandleFunc("/apps", s.handleApps) mux.HandleFunc("/tasks", s.handleTasks) mux.HandleFunc("/hwaccel", s.handleHWAccel) mux.HandleFunc("/config", s.handleConfig) mux.HandleFunc("/settings", s.handleSettings) mux.HandleFunc("/system", s.handleSystem) mux.HandleFunc("/logs", s.handleLogs) mux.HandleFunc("/languages", s.handleLanguages) mux.HandleFunc("/hotkeys", s.handleHotkeys) mux.HandleFunc("/api/platform", s.handlePlatformAPI) mux.HandleFunc("/api/apps", s.handleAppsAPI) mux.HandleFunc("/api/install-methods", s.handleInstallMethodsAPI) mux.HandleFunc("/api/config/settings", s.handleConfigSettingsAPI) mux.HandleFunc("/api/config/backups", s.handleConfigBackupsAPI) mux.HandleFunc("/api/config/backups/list", s.handleConfigBackupsListAPI) mux.HandleFunc("/api/config/apply", s.handleConfigApplyAPI) mux.HandleFunc("/api/config/reset", s.handleConfigResetAPI) mux.HandleFunc("/api/config/restore", s.handleConfigRestoreAPI) mux.HandleFunc("/api/config/backup/delete", s.handleConfigBackupDeleteAPI) mux.HandleFunc("/api/install", s.handleInstallAPI) mux.HandleFunc("/api/install/stream", s.handleInstallStreamSSE) mux.HandleFunc("/api/uninstall", s.handleUninstallAPI) mux.HandleFunc("/api/file-associations/setup", s.handleFileAssociationsSetupAPI) mux.HandleFunc("/api/file-associations/remove", s.handleFileAssociationsRemoveAPI) mux.HandleFunc("/api/shortcuts/create", s.handleShortcutsCreateAPI) mux.HandleFunc("/api/logs", s.handleLogsAPI) mux.HandleFunc("/api/logs/clear", s.handleLogsClearAPI) mux.HandleFunc("/api/logs/download", s.handleLogsDownloadAPI) mux.HandleFunc("/api/logs/errors", s.handleRecentErrorsAPI) mux.HandleFunc("/api/apps/check-updates", s.handleCheckAppUpdatesAPI) mux.HandleFunc("/api/apps/refresh-installed", s.handleRefreshInstalledAppsAPI) mux.HandleFunc("/api/apps/update", s.handleAppUpdateAPI) mux.HandleFunc("/api/apps/adopt", s.handleAdoptAppAPI) mux.HandleFunc("/api/manager/update", s.handleManagerUpdateAPI) mux.HandleFunc("/api/languages/load", s.handleLanguagesLoadAPI) mux.HandleFunc("/api/languages/save", s.handleLanguagesSaveAPI) mux.HandleFunc("/api/languages/major", s.handleMajorLanguagesAPI) mux.HandleFunc("/api/languages/regional", s.handleRegionalVariantsAPI) mux.HandleFunc("/api/languages/regions", s.handleAllRegionsAPI) mux.HandleFunc("/api/modal", s.handleModalAPI) mux.HandleFunc("/api/shutdown", s.handleShutdownAPI) mux.HandleFunc("/api/server/status", s.handleServerStatusAPI) mux.HandleFunc("/api/settings", s.handleSettingsAPI) mux.HandleFunc("/api/settings/shortcut", s.handleSettingsShortcutAPI) mux.HandleFunc("/api/settings/reset", s.handleSettingsResetAPI) // Job API endpoints (new job-based system) mux.HandleFunc("/api/jobs", s.handleJobsListAPI) mux.HandleFunc("/api/jobs/stream", s.handleJobStreamSSE) mux.HandleFunc("/api/jobs/", s.handleJobRoutes) // Handles GET /{id} and DELETE /{id} // Job-based install/update/uninstall endpoints (new) mux.HandleFunc("/api/install/job", s.handleInstallJobAPI) mux.HandleFunc("/api/apps/update/job", s.handleAppUpdateJobAPI) mux.HandleFunc("/api/uninstall/job", s.handleUninstallJobAPI) // UI selection endpoints (for ModernZ UI support) mux.HandleFunc("/api/ui/options", s.handleUIOptionsAPI) mux.HandleFunc("/api/ui/change", s.handleUIChangeAPI) // Keyring endpoints (for sudo password storage) mux.HandleFunc("/api/keyring/status", s.handleKeyringStatusAPI) mux.HandleFunc("/api/keyring/auth", s.handleKeyringAuthRoutes) // Handles POST (store) and DELETE (clear) // Notice endpoint (for temporary UI banners) mux.HandleFunc("/api/notice", s.handleNoticeAPI) // Hotkey preset endpoints mux.HandleFunc("/api/hotkeys/presets", s.handleHotkeyPresetsAPI) mux.HandleFunc("/api/hotkeys/apply", s.handleHotkeyApplyPresetAPI) mux.HandleFunc("/api/hotkeys/reset", s.handleHotkeyResetAPI) mux.HandleFunc("/api/hotkeys/current", s.handleHotkeyCurrentAPI) mux.Handle("/static/", http.StripPrefix("/static/", fileServer)) mux.HandleFunc("/favicon.ico", s.handleFavicon) log.Info("Web server listening on " + addr) // Create and store http.Server for graceful shutdown s.httpServer = &http.Server{ Addr: addr, Handler: handler, } if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Error("Web server failed to start: " + err.Error()) return err } return nil } // Shutdown gracefully shuts down the server func (s *Server) Shutdown() { if s.httpServer != nil { log.Info("Shutting down HTTP server...") // Create a context with timeout for graceful shutdown ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := s.httpServer.Shutdown(ctx); err != nil { log.Error("Error shutting down server: " + err.Error()) } } } func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) { // Serve favicon.png from static directory data, err := webassets.StaticFS.ReadFile("static/favicon.png") if err != nil { log.Debug("Failed to read favicon: " + err.Error()) http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/png") w.Write(data) } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/dashboard", http.StatusFound) } func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { installedApps := config.GetInstalledApps() installerCheck := version.CheckForUpdate() managerUpdate := &ManagerUpdateInfo{ CurrentVersion: installerCheck.CurrentVersion, LatestVersion: installerCheck.LatestVersion, UpdateAvailable: installerCheck.UpdateAvailable, } // Get client updates from cache s.versionCacheMux.RLock() var updateItems []version.UpdateItem updateMap := make(map[string]version.UpdateItem) for _, update := range s.versionCache.UpdateMap { updateItems = append(updateItems, update) updateMap[update.MethodID] = update } clientUpdates := s.convertClientUpdates(updateItems) s.versionCacheMux.RUnlock() // Group installed apps with logos and version info appGroups := s.groupInstalledAppsWithUpdates(installedApps, updateMap) data := DashboardData{ Title: "Dashboard - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Platform: s.platform, InstalledApps: installedApps, InstalledAppGroups: appGroups, ManagerUpdate: managerUpdate, ClientUpdates: clientUpdates, Config: s.getConfigSettings(), Nav: getActiveNav("/dashboard"), SelfUpdateDisabled: IsSelfUpdateDisabled(), } if err := s.templates.ExecuteTemplate(w, "dashboard", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) getConfigSettings() *ConfigSettings { audioLangs := config.GetConfigValue(constants.ConfigKeyAudioLanguage) subtitleLangs := config.GetConfigValue(constants.ConfigKeySubtitleLanguage) // Read hardware acceleration from mpv.conf instead of hardcoding hwaValue := getMPVConfigValue(constants.ConfigKeyHardwareDecoder) if hwaValue == "" { hwaValue = "auto" // Default if not configured } var audioLang, subtitleLang string if len(audioLangs) > 0 { audioLang = audioLangs[0] } if len(subtitleLangs) > 0 { subtitleLang = subtitleLangs[0] } // Read additional settings from mpv.conf videoOutputDriver := getMPVConfigValue(constants.ConfigKeyVideoOutput) videoScaleFilter := getMPVConfigValue(constants.ConfigKeyScaleFilter) ditherAlgorithm := getMPVConfigValue(constants.ConfigKeyDitherAlgorithm) savePositionOnQuit := getMPVConfigValue(constants.ConfigKeySavePositionOnQuit) border := getMPVConfigValue(constants.ConfigKeyBorder) if border == "" { border = "yes" // Default to native window decorations } profile := getMPVConfigValue(constants.ConfigKeyProfile) // Load screenshot settings screenshotFormat := getMPVConfigValue(constants.ConfigKeyScreenshotFormat) screenshotTagColorspace := getMPVConfigValue(constants.ConfigKeyScreenshotTagColorspace) screenshotHighBitDepth := getMPVConfigValue(constants.ConfigKeyScreenshotHighBitDepth) screenshotTemplate := getMPVConfigValue(constants.ConfigKeyScreenshotTemplate) screenshotDirectory := getMPVConfigValue(constants.ConfigKeyScreenshotDirectory) screenshotJpegQuality := getMPVConfigValue(constants.ConfigKeyScreenshotJpegQuality) screenshotPngCompression := getMPVConfigValue(constants.ConfigKeyScreenshotPngCompression) screenshotWebpLossless := getMPVConfigValue(constants.ConfigKeyScreenshotWebpLossless) screenshotWebpQuality := getMPVConfigValue(constants.ConfigKeyScreenshotWebpQuality) screenshotJxlDistance := getMPVConfigValue(constants.ConfigKeyScreenshotJxlDistance) screenshotAvifEncoder := getMPVConfigValue(constants.ConfigKeyScreenshotAvifEncoder) screenshotAvifPixfmt := getMPVConfigValue(constants.ConfigKeyScreenshotAvifPixfmt) screenshotAvifOpts := getMPVConfigValue(constants.ConfigKeyScreenshotAvifOpts) return &ConfigSettings{ AudioLanguage: audioLang, AudioLanguages: audioLangs, SubtitleLanguage: subtitleLang, SubtitleLanguages: subtitleLangs, HardwareAccel: hwaValue, VideoOutputDriver: videoOutputDriver, VideoScaleFilter: videoScaleFilter, DitherAlgorithm: ditherAlgorithm, SavePositionOnQuit: savePositionOnQuit, Border: border, Profile: profile, ConfigFile: getConfigFilePath(), ScreenshotFormat: screenshotFormat, ScreenshotTagColorspace: screenshotTagColorspace, ScreenshotHighBitDepth: screenshotHighBitDepth, ScreenshotTemplate: screenshotTemplate, ScreenshotDirectory: screenshotDirectory, ScreenshotJpegQuality: screenshotJpegQuality, ScreenshotPngCompression: screenshotPngCompression, ScreenshotWebpLossless: screenshotWebpLossless, ScreenshotWebpQuality: screenshotWebpQuality, ScreenshotJxlDistance: screenshotJxlDistance, ScreenshotAvifEncoder: screenshotAvifEncoder, ScreenshotAvifPixfmt: screenshotAvifPixfmt, ScreenshotAvifOpts: screenshotAvifOpts, } } func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { installedApps := config.GetInstalledApps() installMethods := s.getInstallMethods() // Filter out already installed methods availableMethods := filterInstalledMethods(installMethods, installedApps) // Get update map and client updates from cache s.versionCacheMux.RLock() var updateItems []version.UpdateItem updateMap := make(map[string]version.UpdateItem) for _, update := range s.versionCache.UpdateMap { updateItems = append(updateItems, update) updateMap[update.MethodID] = update } clientUpdates := s.convertClientUpdates(updateItems) s.versionCacheMux.RUnlock() appGroups := s.groupInstalledAppsWithUpdates(installedApps, updateMap) data := DashboardData{ Title: "MPV Player Apps - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Platform: s.platform, InstalledApps: installedApps, InstallMethods: availableMethods, InstalledAppGroups: appGroups, ClientUpdates: clientUpdates, Nav: getActiveNav("/apps"), } if err := s.templates.ExecuteTemplate(w, "apps", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } // filterInstalledMethods removes install methods that are already installed func filterInstalledMethods(methods []installer.InstallMethod, installedApps []config.InstalledApp) []installer.InstallMethod { var filtered []installer.InstallMethod // Create a set of installed method IDs installedMethodIDs := make(map[string]bool) for _, app := range installedApps { installedMethodIDs[app.InstallMethod] = true } // Filter out installed methods for _, method := range methods { if !installedMethodIDs[method.ID] { filtered = append(filtered, method) } } return filtered } func (s *Server) getInstallMethods() []installer.InstallMethod { // Create installation handler for the current platform handler := installer.NewInstallationHandler(s.platform, s.installer) if handler == nil || handler.PlatformInstaller == nil { return []installer.InstallMethod{} } return handler.PlatformInstaller.GetAvailableMethods(true) } func (s *Server) handleHWAccel(w http.ResponseWriter, r *http.Request) { // Get HWA options based on platform and GPU brand hwaOptions := platform.GetHWAOptions(s.platform.OSType, s.platform.GPUInfo.Brand) // Get recommended HWA decoder based on platform and GPU recommendedHWA := platform.GetRecommendedHWADecoder(s.platform.OSType, s.platform.GPUInfo.Brand) // Get current HWA value from config using centralized config function currentHWA := "auto" values := config.GetConfigValue(constants.ConfigKeyHardwareDecoder) if len(values) > 0 { currentHWA = values[0] } data := DashboardData{ Title: "Hardware Acceleration - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Platform: s.platform, HWAOptions: hwaOptions, CurrentHWA: currentHWA, RecommendedHWA: recommendedHWA, Nav: getActiveNav("/hwaccel"), } if err := s.templates.ExecuteTemplate(w, "hwaccel", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { data := DashboardData{ Title: "MPV Config Management - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Platform: s.platform, Nav: getActiveNav("/config"), ConfigBackups: getConfigBackups(), Config: s.getConfigSettings(), } if err := s.templates.ExecuteTemplate(w, "config", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { // Build system info with dynamic values systemInfo := &SystemInfo{ AppName: constants.AppDisplayName, AppVersion: version.CurrentVersion, GoVersion: runtime.Version(), BuildTime: version.BuildTime, RepositoryURL: constants.RepositoryURL, RepositoryName: constants.RepositoryURL, } // Get platform-specific paths if runtime.GOOS == constants.OSWindows { // Windows: Use detected MPV directory (AppData for fresh installs, legacy for existing) mpvBaseDir := constants.GetWindowsMPVBaseDir() systemInfo.InstallPath = mpvBaseDir + string(filepath.Separator) systemInfo.MPVConfigPath = filepath.Join(mpvBaseDir, constants.PortableConfigDir, constants.MPVConfigFileName) systemInfo.LogPath = filepath.Join(mpvBaseDir, constants.PortableConfigDir, "mpv-manager.log") systemInfo.BackupPath = filepath.Join(mpvBaseDir, constants.PortableConfigDir, constants.ConfigBackupsDir) + string(filepath.Separator) } else { // Linux/macOS: Use XDG config home configDir := constants.GetXDGConfigHome() systemInfo.InstallPath = configDir + string(filepath.Separator) + constants.AppNameMPV + string(filepath.Separator) systemInfo.MPVConfigPath = filepath.Join(configDir, constants.AppNameMPV, constants.MPVConfigFileName) systemInfo.LogPath = filepath.Join(configDir, constants.AppNameMPV, "mpv-manager.log") systemInfo.BackupPath = filepath.Join(configDir, constants.AppNameMPV, constants.ConfigBackupsDir) + string(filepath.Separator) } // If build time is empty, show "Development build" if systemInfo.BuildTime == "" { systemInfo.BuildTime = "Development build" } data := DashboardData{ Title: "System Information - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Platform: s.platform, Nav: getActiveNav("/system"), SystemInfo: systemInfo, } if err := s.templates.ExecuteTemplate(w, "system", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { data := DashboardData{ Title: "Logs Viewer - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Platform: s.platform, Nav: getActiveNav("/logs"), } if err := s.templates.ExecuteTemplate(w, "logs", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) handleTasks(w http.ResponseWriter, r *http.Request) { data := DashboardData{ Title: "Tasks - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Platform: s.platform, Nav: getActiveNav("/tasks"), } if err := s.templates.ExecuteTemplate(w, "tasks", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) handleHotkeys(w http.ResponseWriter, r *http.Request) { // Build hotkeys page data categories := buildHotkeyCategories() categoryCounts := buildCategoryCounts() totalCount := len(hotkeys.Hotkeys) data := HotkeysPageData{ Title: "Keyboard Shortcuts - MPV.Rocks Manager", Nav: getActiveNav("/hotkeys"), AppVersion: version.CurrentVersion, Categories: categories, CategoryCounts: categoryCounts, TotalCount: totalCount, } if err := s.templates.ExecuteTemplate(w, "hotkeys", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } // buildHotkeyCategories builds the categories with hotkeys for display func buildHotkeyCategories() []HotkeyCategoryData { var categories []HotkeyCategoryData for _, cat := range hotkeys.Categories { catHotkeys := hotkeys.GetHotkeysByCategory(cat) if len(catHotkeys) == 0 { continue } var items []HotkeyItem for _, h := range catHotkeys { items = append(items, HotkeyItem{ ID: h.ID, Keys: h.Keys, Description: h.Description, Category: h.Category, Priority: h.Priority, }) } categories = append(categories, HotkeyCategoryData{ Category: cat, Hotkeys: items, Count: len(items), }) } return categories } // buildCategoryCounts builds category count items for filter buttons func buildCategoryCounts() []CategoryCountItem { counts := hotkeys.GetHotkeysCountByCategory() var items []CategoryCountItem for _, cat := range hotkeys.Categories { if count, ok := counts[cat]; ok && count > 0 { items = append(items, CategoryCountItem{ Category: cat, Count: count, }) } } return items } // parseMPVLanguages parses comma-separated language codes from mpv.conf func parseMPVLanguages(value string) []string { if value == "" { return []string{} } // Split by comma and trim whitespace parts := strings.Split(value, ",") var langs []string for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { langs = append(langs, trimmed) } } return langs } func (s *Server) handleLanguages(w http.ResponseWriter, r *http.Request) { // Load current priorities from mpv.conf first, then fall back to config audioMPV := getMPVConfigValue("alang") subtitleMPV := getMPVConfigValue("slang") // Parse MPV config values (comma-separated) audioPriority := parseMPVLanguages(audioMPV) subtitlePriority := parseMPVLanguages(subtitleMPV) // If mpv.conf is empty, use config file preferences if len(audioPriority) == 0 { audioPriority = config.GetConfigValue(constants.ConfigKeyAudioLanguage) } if len(subtitlePriority) == 0 { subtitlePriority = config.GetConfigValue(constants.ConfigKeySubtitleLanguage) } locales, _ := LoadLocales() // Convert to LanguagePriorityData format audioPriorityData := make([]LanguagePriorityData, 0) for i, loc := range audioPriority { // Find locale entry localeEntry, regionEntry := locale.FindByLocale(loc, locales) if localeEntry != nil && regionEntry != nil { audioPriorityData = append(audioPriorityData, LanguagePriorityData{ Index: i, LanguageCode: localeEntry.LanguageCode, LanguageName: localeEntry.LanguageName, LanguageLocal: localeEntry.LanguageLocal, Flag: regionEntry.Flag, Locale: regionEntry.Locale, }) } else { // Try to find by language code (major language only) langEntry := locale.FindByLanguageCode(loc, locales) if langEntry != nil { audioPriorityData = append(audioPriorityData, LanguagePriorityData{ Index: i, LanguageCode: langEntry.LanguageCode, LanguageName: langEntry.LanguageName, LanguageLocal: langEntry.LanguageLocal, Flag: langEntry.LanguageCode, // Will use language code flag Locale: langEntry.LanguageCode, }) } else { // Fallback for unknown locales audioPriorityData = append(audioPriorityData, LanguagePriorityData{ Index: i, LanguageCode: loc, LanguageName: loc, LanguageLocal: loc, Flag: "🏳️", Locale: loc, }) } } } subtitlePriorityData := make([]LanguagePriorityData, 0) for i, loc := range subtitlePriority { // Find locale entry localeEntry, regionEntry := locale.FindByLocale(loc, locales) if localeEntry != nil && regionEntry != nil { subtitlePriorityData = append(subtitlePriorityData, LanguagePriorityData{ Index: i, LanguageCode: localeEntry.LanguageCode, LanguageName: localeEntry.LanguageName, LanguageLocal: localeEntry.LanguageLocal, Flag: regionEntry.Flag, Locale: regionEntry.Locale, }) } else { // Try to find by language code (major language only) langEntry := locale.FindByLanguageCode(loc, locales) if langEntry != nil { subtitlePriorityData = append(subtitlePriorityData, LanguagePriorityData{ Index: i, LanguageCode: langEntry.LanguageCode, LanguageName: langEntry.LanguageName, LanguageLocal: langEntry.LanguageLocal, Flag: langEntry.LanguageCode, // Will use language code flag Locale: langEntry.LanguageCode, }) } else { // Fallback for unknown locales subtitlePriorityData = append(subtitlePriorityData, LanguagePriorityData{ Index: i, LanguageCode: loc, LanguageName: loc, LanguageLocal: loc, Flag: "🏳️", Locale: loc, }) } } } data := LanguagesPageData{ Title: "Language Preferences - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Config: s.getConfigSettings(), MajorLanguages: []MajorLanguageData{}, // Loaded via JS SelectedMajorLanguage: nil, // Set via JS RegionalVariants: []RegionalVariantData{}, // Loaded via JS AudioPriority: audioPriorityData, SubtitlePriority: subtitlePriorityData, Nav: getActiveNav("/languages"), } if err := s.templates.ExecuteTemplate(w, "languages", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func getActiveNav(path string) []NavItem { navItems := make([]NavItem, len(NavItems)) copy(navItems, NavItems) for i := range navItems { if navItems[i].URL == path { navItems[i].Active = true } else { navItems[i].Active = false } } return navItems } func (s *Server) checkForUpdates() *UpdateStatus { return &UpdateStatus{ ManagerUpdateAvailable: false, ManagerCurrentVersion: "0.1.0", ManagerLatestVersion: "0.1.0", AppUpdatesAvailable: 0, Apps: []AppUpdateInfo{}, } } func (s *Server) groupInstallMethods(methods []installer.InstallMethod) map[string][]installer.InstallMethod { groups := make(map[string][]installer.InstallMethod) for _, method := range methods { groups[method.AppType] = append(groups[method.AppType], method) } return groups } func (s *Server) groupInstalledApps(apps []config.InstalledApp) map[string][]AppGroupInfo { groups := make(map[string][]AppGroupInfo) for _, app := range apps { groupKey := app.AppName logo := getLogoForApp(app.InstallMethod, app.AppName) typeTag := getTypeTag(app.InstallMethod) typeLogo := getTypeLogo(typeTag, s.platform) // Determine version to display: // - Package manager apps: use cached version from startup query // - Portable/binary apps: use app.AppVersion from config displayVersion := app.AppVersion if isPackageManagerApp(app.InstallMethod) { s.versionCacheMux.RLock() if versionInfo, ok := s.versionCache.PackageVersionMap[app.InstallMethod]; ok { if versionInfo.CurrentVersion != "" && versionInfo.CurrentVersion != "Unknown" { displayVersion = versionInfo.CurrentVersion } } s.versionCacheMux.RUnlock() } groups[groupKey] = append(groups[groupKey], AppGroupInfo{ App: app, Logo: logo, TypeTag: typeTag, TypeLogo: typeLogo, UIType: app.UIType, Version: displayVersion, AppName: app.AppName, AppType: app.AppType, InstallMethod: app.InstallMethod, Platform: s.platform, Managed: app.Managed, }) } return groups } func (s *Server) groupInstalledAppsWithUpdates(apps []config.InstalledApp, updateMap map[string]version.UpdateItem) map[string][]AppGroupInfo { groups := make(map[string][]AppGroupInfo) for _, app := range apps { groupKey := app.AppName logo := getLogoForApp(app.InstallMethod, app.AppName) typeTag := getTypeTag(app.InstallMethod) typeLogo := getTypeLogo(typeTag, s.platform) updateAvailable := false updateChecked := false var currentVersion, availableVersion string // Check for update in updateMap if updateItem, ok := updateMap[app.InstallMethod]; ok { // Handle both update-available and current-version cases // UpdateType "update" means there's an update available // UpdateType "current-version" means we have version info but no update if updateItem.UpdateType == "update" { updateAvailable = true } currentVersion = updateItem.CurrentVersion availableVersion = updateItem.AvailableVersion updateChecked = true } else { // Package manager apps: use cached version info if isPackageManagerApp(app.InstallMethod) { s.versionCacheMux.RLock() if versionInfo, ok := s.versionCache.PackageVersionMap[app.InstallMethod]; ok { currentVersion = versionInfo.CurrentVersion availableVersion = versionInfo.AvailableVersion updateChecked = true } s.versionCacheMux.RUnlock() } } // Determine version to display: // - Package manager apps: use currentVersion from cache (queried at startup) // - Portable/binary apps: use app.AppVersion (saved to config at install time) displayVersion := app.AppVersion if isPackageManagerApp(app.InstallMethod) && currentVersion != "" && currentVersion != "Unknown" { displayVersion = currentVersion } groups[groupKey] = append(groups[groupKey], AppGroupInfo{ App: app, Logo: logo, TypeTag: typeTag, TypeLogo: typeLogo, UpdateAvailable: updateAvailable, UpdateChecked: updateChecked, CurrentVersion: currentVersion, AvailableVersion: availableVersion, Platform: s.platform, AppName: app.AppName, AppType: app.AppType, InstallMethod: app.InstallMethod, UIType: app.UIType, Version: displayVersion, Managed: app.Managed, }) } return groups } func isPackageManagerApp(methodID string) bool { pmApps := []string{ constants.MethodMPVFlatpak, constants.MethodMPVPackage, constants.MethodCelluloidFlatpak, constants.MethodCelluloidPackage, constants.MethodMPVBrew, } for _, pmApp := range pmApps { if methodID == pmApp { return true } } return false } func getLogoForApp(method, appName string) string { // Check based on install method ID to determine the app switch { case strings.Contains(method, constants.MethodMPVBinary), strings.Contains(method, constants.MethodMPVPackage), strings.Contains(method, constants.MethodMPVFlatpak), strings.Contains(method, constants.MethodMPVBrew), strings.Contains(method, constants.MethodMPVApp): return "mpv.avif" case strings.Contains(method, constants.MethodCelluloidPackage), strings.Contains(method, constants.MethodCelluloidFlatpak): return "celluloid.avif" case strings.Contains(method, constants.MethodIINA): return "iina.avif" case strings.Contains(method, constants.MethodMPCQT): return "mpc.avif" case strings.Contains(method, constants.MethodUOSC): return "uosc.avif" case strings.Contains(method, constants.MethodModernZ): return "modernz.avif" case strings.Contains(method, constants.MethodFFmpeg): return "ffmpeg.avif" } // Fallback: try to match by app name (legacy support) switch strings.ToLower(appName) { case "mpv": return "mpv.avif" case "celluloid": return "celluloid.avif" case "iina": return "iina.avif" case "mpc-qt", "mpc-hc": return "mpc.avif" case "infuse": return "infuse.avif" case "jellyfin": return "jellyfin.avif" case "uosc": return "uosc.avif" case "modernz": return "modernz.avif" case "ffmpeg": return "ffmpeg.avif" } return "mpv.avif" } func getTypeTag(method string) string { switch method { case "mpv-binary", "mpv-binary-v3", "mpv-app": return "Portable" case "mpv-package", "celluloid-package", "mpv-brew": return "Package Manager" case "mpv-flatpak", "celluloid-flatpak": return "Flatpak" case "mpc-qt", "iina": return "Installed App" case "uosc": return "UI Component" case "modernz": return "UI Component" case "ffmpeg": return "Utility" default: return "" } } // getTypeLogo returns the logo filename for the install method type func getTypeLogo(typeTag string, p *platform.Platform) string { switch typeTag { case "Flatpak": return "flatpak.avif" case "Portable": return "portable.avif" case "Package Manager": // On macOS, return brew icon since Homebrew is the package manager if p != nil && p.IsDarwin() { return "brew.avif" } // On Linux, return empty string - caller should use distro logo instead return "" case "Installed App": // Return platform-specific icon for installed apps if p != nil { if p.IsDarwin() { return "mac-app.avif" } if p.IsWindows() { return "win-app.avif" } } // No fallback for Linux - Installed App type is only for macOS/Windows return "" default: return "" } } // canChangeUI returns true if the installed app supports changing UI overlays // This is true for MPV binary/portable installs, Flatpak, and package manager MPV func canChangeUI(installMethod string) bool { switch installMethod { case constants.MethodMPVBinary, constants.MethodMPVBinaryV3, constants.MethodMPVApp: // Windows portable and macOS MPV.app return true case constants.MethodMPVFlatpak, constants.MethodMPVPackage, constants.MethodMPVBrew: // Flatpak (with filesystem override) and package manager MPV use ~/.config/mpv/ return true default: return false } } // cleanAppVersion removes package name prefix from version string for display // e.g., "mpv 1:0.41.0-2.1" → "1:0.41.0-2.1" // // "celluloid 0.29-1.1" → "0.29-1.1" func cleanAppVersion(version string) string { if version == "" { return "" } // Try to extract version after package name // Common formats: "pkg 1.0.0", "pkg 1:0.41.0-2.1" fields := strings.Fields(version) if len(fields) > 1 { // Return everything after the first field (package name) return strings.Join(fields[1:], " ") } return version } // compareVersions compares two version strings and returns true if available is newer than current // This is a simple implementation that handles common version formats func compareVersions(current, available string) bool { // Empty versions mean no update available if current == "" || available == "" { return false } // Package manager messages mean no update available if available == "Check via Package Manager" || available == "Unknown" { return false } // If versions are the same, no update if current == available { return false } // If current is "Unknown", we can't determine if update is available // (available might be a placeholder like "Check via Package Manager") if current == "Unknown" { return false } // Versions are different - consider this an update // This handles cases where versions actually differ return true } // getPackageManagerType returns the package manager type for a given method ID func getPackageManagerType(methodID string) string { switch { case strings.Contains(methodID, "flatpak"): return "Flatpak" case strings.Contains(methodID, "package"): return "Package Manager" case strings.Contains(methodID, "brew"): return "Brew" default: return "" } } func (s *Server) convertClientUpdates(updates []version.UpdateItem) []ClientUpdateInfo { var result []ClientUpdateInfo s.versionCacheMux.RLock() defer s.versionCacheMux.RUnlock() for _, item := range updates { logo := getLogoForApp(item.MethodID, item.AppName) // Check if this is a package manager app versionInfo, isPackageManager := s.versionCache.PackageVersionMap[item.MethodID] var updateAvailable bool var currentVersion, availableVersion string var packageManager string if isPackageManager { // For package manager apps, compare versions currentVersion = versionInfo.CurrentVersion availableVersion = versionInfo.AvailableVersion updateAvailable = compareVersions(currentVersion, availableVersion) packageManager = getPackageManagerType(item.MethodID) } else { // For release-based apps, update is available if UpdateType is "update" currentVersion = item.CurrentVersion availableVersion = item.AvailableVersion updateAvailable = item.UpdateType == "update" } // Only add apps that actually have updates available // This prevents showing "Up to Date" buttons in the Available Updates section if !updateAvailable { continue } result = append(result, ClientUpdateInfo{ AppName: item.AppName, CurrentVersion: cleanAppVersion(currentVersion), AvailableVersion: cleanAppVersion(availableVersion), MethodID: item.MethodID, Logo: logo, UpdateAvailable: true, PackageManager: packageManager, }) } return result } // getAppLogo returns the logo filename for an app (template function) func getAppLogo(appName string) string { return getLogoForApp("", appName) } // initVersionCache initializes the version check cache at startup // This is a blocking operation that: // 1. Detects newly installed apps (not tracked in config) // 2. Removes stale apps (no longer installed) // 3. Checks for updates func (s *Server) initVersionCache() { log.Info("Initializing version check cache...") // Step 1: Detect and sync installed apps (Linux only) // This finds apps installed outside of mpv-manager and adds them to tracking if runtime.GOOS == "linux" { log.Info("Detecting installed MPV apps...") added, removed := installer.DetectAndSyncApps() if added > 0 { log.Info(fmt.Sprintf("Added %d newly detected apps to config", added)) } if removed > 0 { log.Info(fmt.Sprintf("Removed %d stale apps from config", removed)) } } // Step 2: Get installed apps (now includes any newly detected) installedApps := config.GetInstalledApps() appUpdates := version.CheckForAppUpdates(s.releaseInfo, installedApps) s.versionCacheMux.Lock() defer s.versionCacheMux.Unlock() // Initialize cache structure s.versionCache = &VersionCache{ LastChecked: time.Now(), UpdateMap: make(map[string]version.UpdateItem), PackageVersionMap: make(map[string]AppVersionInfo), } // Store update information from CheckForAppUpdates for _, update := range appUpdates.AppsToUpdate { s.versionCache.UpdateMap[update.MethodID] = update } // Step 3: Query package manager versions concurrently // Also detects and removes stale apps (no longer installed) versionMap, staleApps := s.concurrentlyCheckPackageVersions(installedApps) for methodID, versionInfo := range versionMap { s.versionCache.PackageVersionMap[methodID] = versionInfo } // Remove stale apps from config (apps that were uninstalled externally) // This is a secondary check in case detection missed something if len(staleApps) > 0 { log.Info(fmt.Sprintf("Removing %d stale apps from config (no longer installed)", len(staleApps))) for _, methodID := range staleApps { if err := config.RemoveInstalledAppByMethod(methodID); err != nil { log.Warn(fmt.Sprintf("Failed to remove stale app %s: %v", methodID, err)) } else { log.Info("Removed stale app from config: " + methodID) } } } log.Info(fmt.Sprintf("Version check cache initialized with %d updates and %d package versions", len(s.versionCache.UpdateMap), len(s.versionCache.PackageVersionMap))) } // concurrentlyCheckPackageVersions checks all package manager versions concurrently // Returns version map and list of method IDs for apps that are no longer installed func (s *Server) concurrentlyCheckPackageVersions(installedApps []config.InstalledApp) (map[string]AppVersionInfo, []string) { // Collect package manager apps var pmApps []config.InstalledApp for _, app := range installedApps { if isPackageManagerApp(app.InstallMethod) { pmApps = append(pmApps, app) } } // If no package manager apps, return empty map if len(pmApps) == 0 { return make(map[string]AppVersionInfo), nil } // Create channel for results with buffer resultChan := make(chan PackageVersionResult, len(pmApps)) // Start goroutines for concurrent version checking for _, app := range pmApps { go func(app config.InstalledApp) { currentVersion := GetPackageVersion(app.InstallMethod, app.AppName) availableVersion := GetAvailableVersion(app.InstallMethod, app.AppName) // Detect if package is not installed // Empty currentVersion for package manager apps means not installed found := currentVersion != "" && currentVersion != "Unknown" if currentVersion == "" { currentVersion = "Unknown" } if availableVersion == "" { availableVersion = "Check via Package Manager" } resultChan <- PackageVersionResult{ MethodID: app.InstallMethod, CurrentVersion: currentVersion, AvailableVersion: availableVersion, Found: found, } }(app) } // Collect results with timeout (10 seconds total) versionMap := make(map[string]AppVersionInfo) var staleApps []string timeout := time.After(10 * time.Second) for i := 0; i < len(pmApps); i++ { select { case result := <-resultChan: versionMap[result.MethodID] = AppVersionInfo{ CurrentVersion: result.CurrentVersion, AvailableVersion: result.AvailableVersion, } if !result.Found { staleApps = append(staleApps, result.MethodID) } case <-timeout: log.Warn("Timeout while checking package versions, some results may be missing") // Fill in remaining apps as unknown for _, app := range pmApps { if _, ok := versionMap[app.InstallMethod]; !ok { versionMap[app.InstallMethod] = AppVersionInfo{ CurrentVersion: "Unknown", AvailableVersion: "Check via Package Manager", } } } break } } return versionMap, staleApps } // refreshVersionCache refreshes the version check cache func (s *Server) refreshVersionCache() { log.Info("Refreshing version check cache...") // Detect and sync installed apps (Linux only) if runtime.GOOS == "linux" { added, removed := installer.DetectAndSyncApps() if added > 0 { log.Info(fmt.Sprintf("Added %d newly detected apps to config", added)) } if removed > 0 { log.Info(fmt.Sprintf("Removed %d stale apps from config", removed)) } } // Get installed apps (now includes any newly detected) installedApps := config.GetInstalledApps() appUpdates := version.CheckForAppUpdates(s.releaseInfo, installedApps) s.versionCacheMux.Lock() defer s.versionCacheMux.Unlock() // Update last checked time s.versionCache.LastChecked = time.Now() // Clear and rebuild update map s.versionCache.UpdateMap = make(map[string]version.UpdateItem) for _, update := range appUpdates.AppsToUpdate { s.versionCache.UpdateMap[update.MethodID] = update } // Query package manager versions for package manager apps concurrently // Also detects and removes stale apps (no longer installed) versionMap, staleApps := s.concurrentlyCheckPackageVersions(installedApps) for methodID, versionInfo := range versionMap { s.versionCache.PackageVersionMap[methodID] = versionInfo } // Remove stale apps from config (apps that were uninstalled externally) // This is a secondary check in case detection missed something if len(staleApps) > 0 { log.Info(fmt.Sprintf("Removing %d stale apps from config (no longer installed)", len(staleApps))) for _, methodID := range staleApps { if err := config.RemoveInstalledAppByMethod(methodID); err != nil { log.Warn(fmt.Sprintf("Failed to remove stale app %s: %v", methodID, err)) } else { log.Info("Removed stale app from config: " + methodID) } } } log.Info(fmt.Sprintf("Version check cache refreshed: %d updates, %d package versions", len(s.versionCache.UpdateMap), len(s.versionCache.PackageVersionMap))) } // handleSettings renders the settings page func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { // Build system info with config file path systemInfo := &SystemInfo{} configPath := getConfigFilePath() systemInfo.MPVConfigPath = configPath data := DashboardData{ Title: "Settings - MPV.Rocks Manager", AppVersion: version.CurrentVersion, Platform: s.platform, Nav: getActiveNav("/settings"), SystemInfo: systemInfo, } if err := s.templates.ExecuteTemplate(w, "settings", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } // SetPendingNotice delegates to the installer package's notice function func (s *Server) SetPendingNotice(notice string) { installer.SetPendingNotice(notice) } // GetAndClearPendingNotice delegates to the installer package's notice function func (s *Server) GetAndClearPendingNotice() string { return installer.GetAndClearPendingNotice() } // GetPendingNotice delegates to the installer package's notice function func (s *Server) GetPendingNotice() string { return installer.GetPendingNotice() }