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()
}