package jellycompat import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "syscall" "time" "github.com/Silo-Server/silo-server/internal/config" ) const ( DefaultWebSourceURL = "https://github.com/jellyfin/jellyfin-web.git" webMetadataFile = "SILO-JELLYFIN-WEB.json" webSourceFile = "SILO-JELLYFIN-WEB-SOURCE.txt" webInstallLock = ".installing" webLastError = ".last-error" webTempMarker = ".silo-jellyfin-web-temp" webMalformedLockGrace = 2 * time.Minute webOperationStaleAge = 90 * time.Minute ) var ( ErrWebComponentOperationActive = errors.New("jellyfin web operation already running") ErrWebInstallerUnavailable = errors.New("jellyfin web installer prerequisites are missing") webVersionPattern = regexp.MustCompile(`^[0-9]+[.][0-9]+[.][0-9]+(?:[-.][A-Za-z0-9]+)*$`) webOperationsMu sync.Mutex webOperations = map[string]*WebComponentOperationStatus{} ) type WebComponentState string const ( WebComponentMissing WebComponentState = "missing" WebComponentInstalling WebComponentState = "installing" WebComponentRemoving WebComponentState = "removing" WebComponentInstalled WebComponentState = "installed" WebComponentFailed WebComponentState = "failed" WebComponentUpdateAvailable WebComponentState = "update_available" ) type WebComponentOperationKind string const ( WebComponentOperationInstall WebComponentOperationKind = "install" WebComponentOperationRemove WebComponentOperationKind = "remove" ) type WebComponentOperationState string const ( WebComponentOperationRunning WebComponentOperationState = "running" WebComponentOperationSucceeded WebComponentOperationState = "succeeded" WebComponentOperationFailed WebComponentOperationState = "failed" ) type WebComponentOperationPhase string const ( WebComponentOperationPreparing WebComponentOperationPhase = "preparing" WebComponentOperationDownloading WebComponentOperationPhase = "downloading" WebComponentOperationInstalling WebComponentOperationPhase = "installing_dependencies" WebComponentOperationBuilding WebComponentOperationPhase = "building" WebComponentOperationStaging WebComponentOperationPhase = "staging" WebComponentOperationActivating WebComponentOperationPhase = "activating" WebComponentOperationPersisting WebComponentOperationPhase = "persisting_settings" WebComponentOperationRemoving WebComponentOperationPhase = "removing" ) type WebComponentMetadata struct { Component string `json:"component"` SourceURL string `json:"source_url"` Version string `json:"version"` Tag string `json:"tag"` CommitSHA string `json:"commit_sha"` Checksum string `json:"checksum"` BuildCommand string `json:"build_command"` InstalledAt string `json:"installed_at"` Modified bool `json:"modified"` License string `json:"license"` } type WebInstallerPrerequisite struct { Name string `json:"name"` Command string `json:"command"` Available bool `json:"available"` Path string `json:"path,omitempty"` Message string `json:"message,omitempty"` } type WebComponentOperationStatus struct { ID string `json:"id"` Kind WebComponentOperationKind `json:"kind"` State WebComponentOperationState `json:"state"` PID int `json:"pid,omitempty"` Process string `json:"process,omitempty"` Host string `json:"host,omitempty"` StartedAt string `json:"started_at"` CompletedAt string `json:"completed_at,omitempty"` Phase WebComponentOperationPhase `json:"phase,omitempty"` ProgressPercent int `json:"progress_percent,omitempty"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` lockMTime time.Time `json:"-"` malformed bool `json:"-"` } type WebComponentStatus struct { Enabled bool `json:"enabled"` APIState string `json:"api_state"` Listen string `json:"listen"` PublicURL string `json:"public_url"` EmulatedVersion string `json:"emulated_server_version"` ServerName string `json:"server_name"` WebEnabled bool `json:"web_enabled"` WebState WebComponentState `json:"web_state"` PinnedVersion string `json:"pinned_version"` InstalledVersion string `json:"installed_version,omitempty"` SourceURL string `json:"source_url"` Tag string `json:"tag,omitempty"` CommitSHA string `json:"commit_sha,omitempty"` Checksum string `json:"checksum,omitempty"` InstallRoot string `json:"install_root"` InstallPath string `json:"install_path"` InstalledAt string `json:"installed_at,omitempty"` LicensePresent bool `json:"license_present"` ProvenancePresent bool `json:"provenance_present"` InstallerReady bool `json:"installer_ready"` Prerequisites []WebInstallerPrerequisite `json:"prerequisites"` Operation *WebComponentOperationStatus `json:"operation,omitempty"` LastError string `json:"last_error,omitempty"` RestartRequired bool `json:"restart_required"` } type WebComponentInstallOptions struct { InstallRoot string SourceURL string Version string Now func() time.Time RunCommand func(context.Context, string, []string, string) error OnProgress func(WebComponentOperationStatus) } type WebComponentRemoveOptions struct { InstallRoot string OnProgress func(WebComponentOperationStatus) } type WebComponentInstallCompleteFunc func(context.Context, WebComponentStatus) error func DefaultWebInstallRoot(cfg *config.Config) string { if cfg != nil && strings.TrimSpace(cfg.JellyfinCompat.WebInstallDir) != "" { return strings.TrimSpace(cfg.JellyfinCompat.WebInstallDir) } return config.DefaultJellyfinWebInstallDir } func DefaultWebInstallPath(cfg *config.Config) string { return ManagedWebInstallPath(DefaultWebInstallRoot(cfg)) } func ManagedWebInstallPath(root string) string { root = strings.TrimSpace(root) if root == "" { root = config.DefaultJellyfinWebInstallDir } return filepath.Join(root, "current") } func DefaultWebVersion(cfg *config.Config) string { if cfg != nil && strings.TrimSpace(cfg.JellyfinCompat.WebVersion) != "" { return strings.TrimSpace(cfg.JellyfinCompat.WebVersion) } return config.DefaultJellyfinWebVersion } func ResolveCompatibleWebVersion(ctx context.Context, sourceURL, apiVersion string) (string, error) { if ctx == nil { ctx = context.Background() } sourceURL, err := normalizeWebSourceURL(sourceURL) if err != nil { return "", err } versions, err := listRemoteWebVersions(ctx, sourceURL) if err != nil { return "", fmt.Errorf("list Jellyfin Web versions: %w", err) } version, err := SelectCompatibleWebVersion(apiVersion, versions) if err != nil { return "", err } return version, nil } func SelectCompatibleWebVersion(apiVersion string, available []string) (string, error) { target, ok := parseStableWebVersion(apiVersion) if !ok { return "", fmt.Errorf("invalid Jellyfin API version %q", strings.TrimSpace(apiVersion)) } byVersion := map[string]webStableVersion{} for _, raw := range available { version, ok := parseStableWebVersion(raw) if !ok { continue } byVersion[version.String()] = version } if len(byVersion) == 0 { return "", errors.New("no stable Jellyfin Web versions found") } versions := make([]webStableVersion, 0, len(byVersion)) for _, version := range byVersion { versions = append(versions, version) } sort.Slice(versions, func(i, j int) bool { return compareStableWebVersions(versions[i], versions[j]) > 0 }) for _, version := range versions { if version.major == target.major && version.minor == target.minor { return version.String(), nil } } for _, version := range versions { if compareStableWebVersionMinor(version, target) < 0 { return version.String(), nil } } return versions[len(versions)-1].String(), nil } func listRemoteWebVersions(ctx context.Context, sourceURL string) ([]string, error) { if _, err := normalizeWebSourceURL(sourceURL); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/jellyfin/jellyfin-web/releases?per_page=100", nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "silo-jellyfin-web-installer") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("GitHub releases request returned %s", resp.Status) } return parseRemoteWebReleaseVersions(resp.Body) } type webReleaseVersion struct { TagName string `json:"tag_name"` Draft bool `json:"draft"` Prerelease bool `json:"prerelease"` } func parseRemoteWebReleaseVersions(r io.Reader) ([]string, error) { var releases []webReleaseVersion if err := json.NewDecoder(io.LimitReader(r, 4<<20)).Decode(&releases); err != nil { return nil, err } versions := []string{} for _, release := range releases { if release.Draft || release.Prerelease { continue } version := normalizeWebVersion(release.TagName) if version == "" { continue } versions = append(versions, version) } return versions, nil } type webStableVersion struct { major int minor int patch int } func parseStableWebVersion(raw string) (webStableVersion, bool) { version := normalizeWebVersion(raw) parts := strings.Split(version, ".") if len(parts) != 3 { return webStableVersion{}, false } major, err := strconv.Atoi(parts[0]) if err != nil { return webStableVersion{}, false } minor, err := strconv.Atoi(parts[1]) if err != nil { return webStableVersion{}, false } patch, err := strconv.Atoi(parts[2]) if err != nil { return webStableVersion{}, false } return webStableVersion{major: major, minor: minor, patch: patch}, true } func (v webStableVersion) String() string { return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) } func compareStableWebVersionMinor(left, right webStableVersion) int { if left.major != right.major { return left.major - right.major } return left.minor - right.minor } func compareStableWebVersions(left, right webStableVersion) int { if diff := compareStableWebVersionMinor(left, right); diff != 0 { return diff } return left.patch - right.patch } func WebComponentStatusForConfig(cfg *config.Config, settings map[string]string) WebComponentStatus { enabled := configuredCompatEnabled(cfg, settings) configuredWebEnabled := true if cfg != nil { configuredWebEnabled = cfg.JellyfinCompat.WebEnabled } var webEnabledError string if raw := strings.TrimSpace(settings["jellyfin_compat.web_enabled"]); raw != "" { parsed, err := strconv.ParseBool(raw) if err != nil { webEnabledError = fmt.Sprintf("invalid Jellyfin Web UI enabled setting %q", raw) } else { configuredWebEnabled = parsed } } webEnabled := enabled && configuredWebEnabled root := stringSetting(settings, "jellyfin_compat.web_install_dir", DefaultWebInstallRoot(cfg)) webDir := stringSetting(settings, "jellyfin_compat.web_dir", DefaultWebInstallPath(cfg)) pinned := stringSetting(settings, "jellyfin_compat.web_version", DefaultWebVersion(cfg)) sourceURL := stringSetting(settings, "jellyfin_compat.web_source_url", DefaultWebSourceURL) status := webComponentStatus(root, webDir, pinned, sourceURL) status.Enabled = enabled status.WebEnabled = webEnabled if webEnabledError != "" { status.LastError = appendStatusError(status.LastError, webEnabledError) } if cfg != nil { status.Listen = cfg.JellyfinCompat.Listen status.PublicURL = cfg.JellyfinCompat.PublicURL status.EmulatedVersion = cfg.JellyfinCompat.EmulatedServerVersion status.ServerName = cfg.JellyfinCompat.ServerName } status.Listen = stringSetting(settings, "jellyfin_compat.listen", status.Listen) status.PublicURL = stringSetting(settings, "jellyfin_compat.public_url", status.PublicURL) status.EmulatedVersion = stringSetting(settings, "jellyfin_compat.emulated_server_version", status.EmulatedVersion) status.ServerName = stringSetting(settings, "jellyfin_compat.server_name", status.ServerName) status.APIState = "disabled" if enabled { status.APIState = "enabled" if status.Listen == "" { status.APIState = "error" status.LastError = appendStatusError(status.LastError, "jellyfin compatibility listen address is empty") } } if cfg != nil { status.RestartRequired = enabled != cfg.JellyfinCompat.Enabled || strings.TrimSpace(status.Listen) != strings.TrimSpace(cfg.JellyfinCompat.Listen) } return status } func StartWebComponentInstall(opts WebComponentInstallOptions, onComplete WebComponentInstallCompleteFunc) (WebComponentStatus, error) { opts, status, err := normalizeWebInstallOptions(opts) if err != nil { return status, err } if opts.RunCommand == nil { if err := ensureWebInstallerPrerequisites(); err != nil { status.LastError = err.Error() return status, err } } op, err := beginWebOperation(opts.InstallRoot, WebComponentOperationInstall) if err != nil { status.Operation = currentWebOperation(opts.InstallRoot) if status.Operation != nil && status.Operation.State == WebComponentOperationRunning { status.WebState = WebComponentInstalling } return status, err } status.Operation = op status.WebState = WebComponentInstalling notifyWebOperationProgress(opts.OnProgress, op) go func() { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Minute) defer cancel() installStatus, installErr := installWebComponentLocked(ctx, opts, op.ID) if installErr == nil && onComplete != nil { notifyWebOperationProgress( opts.OnProgress, updateWebOperationProgress(opts.InstallRoot, op.ID, WebComponentOperationPersisting, 98, "Persisting Jellyfin Web settings"), ) installErr = onComplete(context.Background(), installStatus) if installErr != nil { writeWebInstallError(opts.InstallRoot, installErr) } } notifyWebOperationProgress(opts.OnProgress, finishWebOperation(opts.InstallRoot, op.ID, installErr)) }() return status, nil } func StartWebComponentRemove(opts WebComponentRemoveOptions) (WebComponentStatus, error) { root, err := normalizeWebInstallRoot(opts.InstallRoot) status := webComponentStatus(root, ManagedWebInstallPath(root), "", "") if err != nil { status.LastError = err.Error() return status, err } op, err := beginWebOperation(root, WebComponentOperationRemove) if err != nil { status.Operation = currentWebOperation(root) if status.Operation != nil && status.Operation.State == WebComponentOperationRunning { status.WebState = WebComponentRemoving } return status, err } status.Operation = op status.WebState = WebComponentRemoving notifyWebOperationProgress(opts.OnProgress, op) go func() { notifyWebOperationProgress( opts.OnProgress, updateWebOperationProgress(root, op.ID, WebComponentOperationRemoving, 50, "Removing managed Jellyfin Web assets"), ) err := removeWebComponentLocked(root) notifyWebOperationProgress(opts.OnProgress, finishWebOperation(root, op.ID, err)) }() return status, nil } func InstallWebComponent(ctx context.Context, opts WebComponentInstallOptions) (WebComponentStatus, error) { opts, status, err := normalizeWebInstallOptions(opts) if err != nil { return status, err } if opts.RunCommand == nil { if err := ensureWebInstallerPrerequisites(); err != nil { status.LastError = err.Error() return status, err } } op, err := beginWebOperation(opts.InstallRoot, WebComponentOperationInstall) if err != nil { status.Operation = currentWebOperation(opts.InstallRoot) if status.Operation != nil && status.Operation.State == WebComponentOperationRunning { status.WebState = WebComponentInstalling } return status, err } notifyWebOperationProgress(opts.OnProgress, op) installStatus, err := installWebComponentLocked(ctx, opts, op.ID) notifyWebOperationProgress(opts.OnProgress, finishWebOperation(opts.InstallRoot, op.ID, err)) return installStatus, err } func installWebComponentLocked(ctx context.Context, opts WebComponentInstallOptions, opID string) (WebComponentStatus, error) { root := opts.InstallRoot sourceURL := opts.SourceURL version := opts.Version now := opts.Now if now == nil { now = time.Now } run := opts.RunCommand if run == nil { run = runWebInstallCommand } reportProgress := func(phase WebComponentOperationPhase, progressPercent int, message string) { notifyWebOperationProgress( opts.OnProgress, updateWebOperationProgress(root, opID, phase, progressPercent, message), ) } reportProgress(WebComponentOperationPreparing, 2, "Preparing Jellyfin Web install directory") if err := os.MkdirAll(root, 0o755); err != nil { return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationPreparing, 5, "Creating temporary install workspace") tmpRoot, err := os.MkdirTemp(root, ".install-*") if err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } defer os.RemoveAll(tmpRoot) if err := os.WriteFile(filepath.Join(tmpRoot, webTempMarker), []byte("silo jellyfin web install workspace\n"), 0o644); err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } srcDir := filepath.Join(tmpRoot, "src") tag := "v" + version reportProgress(WebComponentOperationDownloading, 10, fmt.Sprintf("Downloading Jellyfin Web %s", tag)) if err := run(ctx, "", []string{"git", "clone", "--depth", "1", "--branch", tag, sourceURL, srcDir}, ""); err != nil { err = fmt.Errorf("clone jellyfin-web %s: %w", tag, err) writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationDownloading, 25, "Download complete; resolving Jellyfin Web revision") commitSHA, err := commandOutput(ctx, srcDir, "git", "rev-parse", "HEAD") if err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationInstalling, 35, "Installing Jellyfin Web dependencies") if err := run(ctx, srcDir, []string{"npm", "ci"}, ""); err != nil { err = fmt.Errorf("install jellyfin-web dependencies: %w", err) writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationBuilding, 60, "Building Jellyfin Web production assets") if err := run(ctx, srcDir, []string{"npm", "run", "build:production"}, ""); err != nil { err = fmt.Errorf("build jellyfin-web production bundle: %w", err) writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationStaging, 75, "Staging built Jellyfin Web assets") releaseDir := filepath.Join(root, version) stagedDir := filepath.Join(tmpRoot, version) if err := copyDir(filepath.Join(srcDir, "dist"), stagedDir); err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } if err := copyFile(filepath.Join(srcDir, "LICENSE"), filepath.Join(stagedDir, "LICENSE")); err != nil { err = fmt.Errorf("copy jellyfin-web license: %w", err) writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationStaging, 85, "Calculating Jellyfin Web asset checksum") checksum, err := directoryChecksum(stagedDir) if err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } metadata := WebComponentMetadata{ Component: "jellyfin-web", SourceURL: sourceURL, Version: version, Tag: tag, CommitSHA: strings.TrimSpace(commitSHA), Checksum: "sha256:" + checksum, BuildCommand: "npm ci && npm run build:production", InstalledAt: now().UTC().Format(time.RFC3339), Modified: false, License: "GPL-2.0", } if err := writeWebMetadata(stagedDir, metadata); err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } if err := writeWebSourceFile(stagedDir, metadata); err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationStaging, 90, "Validating staged Jellyfin Web assets") if _, err := validateWebComponentDirectory(stagedDir); err != nil { err = fmt.Errorf("validate staged jellyfin-web assets: %w", err) writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationActivating, 94, "Preparing Jellyfin Web activation") if err := ensureCanReplaceWebRelease(releaseDir); err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } if err := os.RemoveAll(releaseDir); err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } if err := os.Rename(stagedDir, releaseDir); err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } reportProgress(WebComponentOperationActivating, 96, fmt.Sprintf("Activating Jellyfin Web %s", version)) if err := activateWebComponent(root, version); err != nil { writeWebInstallError(root, err) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), err } clearWebInstallError(root) return webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL), nil } func RemoveWebComponent(root string) error { root, err := normalizeWebInstallRoot(root) if err != nil { return err } op, err := beginWebOperation(root, WebComponentOperationRemove) if err != nil { return err } err = removeWebComponentLocked(root) finishWebOperation(root, op.ID, err) return err } func removeWebComponentLocked(root string) error { entries, err := os.ReadDir(root) if err != nil { if os.IsNotExist(err) { clearWebInstallError(root) return nil } writeWebInstallError(root, err) return err } removeCurrent := currentLinkTargetsManagedWebRelease(root) for _, entry := range entries { name := entry.Name() path := filepath.Join(root, name) if name == ".current-next" { if entry.Type()&os.ModeSymlink != 0 { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { writeWebInstallError(root, err) return err } } continue } if name == "current" { if !removeCurrent { continue } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { writeWebInstallError(root, err) return err } continue } if entry.IsDir() && strings.HasPrefix(name, ".install-") { if !filePresent(filepath.Join(path, webTempMarker)) { continue } if err := os.RemoveAll(path); err != nil { writeWebInstallError(root, err) return err } continue } if entry.IsDir() && webComponentDirectoryReady(path) { if err := os.RemoveAll(path); err != nil { writeWebInstallError(root, err) return err } } } clearWebInstallError(root) return nil } func normalizeWebInstallOptions(opts WebComponentInstallOptions) (WebComponentInstallOptions, WebComponentStatus, error) { root, err := normalizeWebInstallRoot(opts.InstallRoot) status := webComponentStatus(root, ManagedWebInstallPath(root), opts.Version, opts.SourceURL) if err != nil { status.LastError = err.Error() return opts, status, err } version, err := normalizeRequiredWebVersion(opts.Version) if err != nil { status.InstallRoot = root status.InstallPath = ManagedWebInstallPath(root) status.LastError = err.Error() return opts, status, err } sourceURL, err := normalizeWebSourceURL(opts.SourceURL) if err != nil { status.InstallRoot = root status.InstallPath = ManagedWebInstallPath(root) status.LastError = err.Error() return opts, status, err } if opts.Now == nil { opts.Now = time.Now } opts.InstallRoot = root opts.Version = version opts.SourceURL = sourceURL status = webComponentStatus(root, ManagedWebInstallPath(root), version, sourceURL) return opts, status, nil } func normalizeWebInstallRoot(root string) (string, error) { root = strings.TrimSpace(root) if root == "" { root = config.DefaultJellyfinWebInstallDir } cleaned := filepath.Clean(root) if !filepath.IsAbs(cleaned) { abs, err := filepath.Abs(cleaned) if err != nil { return cleaned, fmt.Errorf("resolve jellyfin web install directory: %w", err) } cleaned = abs } if cleaned == string(filepath.Separator) { return cleaned, errors.New("jellyfin web install directory cannot be the filesystem root") } return cleaned, nil } func normalizeManagedWebInstallPath(root, webDir string) (string, error) { expected := ManagedWebInstallPath(root) expectedAbs, err := cleanAbsolutePath(expected) if err != nil { return expected, err } webDir = strings.TrimSpace(webDir) if webDir == "" { return expectedAbs, nil } actualAbs, err := cleanAbsolutePath(webDir) if err != nil { return expectedAbs, err } if actualAbs != expectedAbs { return expectedAbs, fmt.Errorf("jellyfin web_dir must use managed install path %s", expectedAbs) } return expectedAbs, nil } func cleanAbsolutePath(path string) (string, error) { cleaned := filepath.Clean(strings.TrimSpace(path)) if cleaned == "" || cleaned == "." { cleaned = config.DefaultJellyfinWebDir } if filepath.IsAbs(cleaned) { return cleaned, nil } abs, err := filepath.Abs(cleaned) if err != nil { return cleaned, err } return abs, nil } func ensureCanReplaceWebRelease(path string) error { info, err := os.Stat(path) if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("inspect existing Jellyfin Web release: %w", err) } if !info.IsDir() { return fmt.Errorf("refusing to replace non-directory Jellyfin Web release path %s", path) } if !webComponentDirectoryReady(path) { return fmt.Errorf("refusing to replace %s because it is not a managed Jellyfin Web release", path) } return nil } func validateWebComponentDirectory(path string) (WebComponentMetadata, error) { if !filePresent(filepath.Join(path, "index.html")) { return WebComponentMetadata{}, errors.New("index.html is missing") } if !filePresent(filepath.Join(path, "LICENSE")) { return WebComponentMetadata{}, errors.New("LICENSE is missing") } if !filePresent(filepath.Join(path, webSourceFile)) { return WebComponentMetadata{}, fmt.Errorf("%s is missing", webSourceFile) } metadata, err := readWebMetadata(path) if err != nil { return WebComponentMetadata{}, fmt.Errorf("%s is missing or invalid: %w", webMetadataFile, err) } if metadata.Component != "jellyfin-web" { return WebComponentMetadata{}, fmt.Errorf("unexpected component %q", metadata.Component) } if normalizeWebVersion(metadata.Version) == "" { return WebComponentMetadata{}, fmt.Errorf("invalid Jellyfin Web metadata version %q", metadata.Version) } if strings.TrimSpace(metadata.License) == "" { return WebComponentMetadata{}, errors.New("Jellyfin Web metadata license is missing") } if _, err := normalizeWebSourceURL(metadata.SourceURL); err != nil { return WebComponentMetadata{}, err } return metadata, nil } func webComponentDirectoryReady(path string) bool { _, err := validateWebComponentDirectory(path) return err == nil } func currentLinkTargetsManagedWebRelease(root string) bool { current := filepath.Join(root, "current") info, err := os.Lstat(current) if err != nil || info.Mode()&os.ModeSymlink == 0 { return false } target, err := os.Readlink(current) if err != nil { return false } if !filepath.IsAbs(target) { target = filepath.Join(root, target) } rootAbs, err := cleanAbsolutePath(root) if err != nil { return false } targetAbs, err := cleanAbsolutePath(target) if err != nil { return false } rel, err := filepath.Rel(rootAbs, targetAbs) if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { return false } return webComponentDirectoryReady(targetAbs) } func normalizeRequiredWebVersion(version string) (string, error) { normalized := normalizeWebVersion(version) if normalized == "" { return "", fmt.Errorf("invalid Jellyfin Web version %q", strings.TrimSpace(version)) } return normalized, nil } func normalizeWebSourceURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { raw = DefaultWebSourceURL } parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid Jellyfin Web source URL %q", raw) } if parsed.Scheme != "https" { return "", errors.New("jellyfin web source URL must use https") } host := strings.EqualFold(parsed.Host, "github.com") repoPath := strings.TrimSuffix(parsed.EscapedPath(), ".git") if !host || !strings.EqualFold(repoPath, "/jellyfin/jellyfin-web") { return "", errors.New("jellyfin web source URL must be the official Jellyfin Web repository") } return DefaultWebSourceURL, nil } func CheckWebInstallerPrerequisites() []WebInstallerPrerequisite { prereqs := []WebInstallerPrerequisite{ {Name: "Git", Command: "git"}, {Name: "npm", Command: "npm"}, } for i := range prereqs { path, err := exec.LookPath(prereqs[i].Command) if err == nil { prereqs[i].Available = true prereqs[i].Path = path continue } prereqs[i].Message = fmt.Sprintf("%s is required to download and build Jellyfin Web", prereqs[i].Command) } return prereqs } func ensureWebInstallerPrerequisites() error { var missing []string for _, prereq := range CheckWebInstallerPrerequisites() { if !prereq.Available { missing = append(missing, prereq.Command) } } if len(missing) > 0 { return fmt.Errorf("%w: install %s on the Silo host or container", ErrWebInstallerUnavailable, strings.Join(missing, ", ")) } return nil } func setInitialWebOperationProgress(op *WebComponentOperationStatus) { if op == nil { return } switch op.Kind { case WebComponentOperationRemove: op.Phase = WebComponentOperationRemoving op.ProgressPercent = 5 op.Message = "Removing managed Jellyfin Web assets" default: op.Phase = WebComponentOperationPreparing op.ProgressPercent = 1 op.Message = "Preparing Jellyfin Web install" } } func notifyWebOperationProgress(onProgress func(WebComponentOperationStatus), op *WebComponentOperationStatus) { if onProgress == nil || op == nil { return } onProgress(*op) } func updateWebOperationProgress(root, id string, phase WebComponentOperationPhase, progressPercent int, message string) *WebComponentOperationStatus { if id == "" { return nil } progressPercent = clampWebOperationProgress(progressPercent) webOperationsMu.Lock() op := webOperations[root] if op == nil || op.ID != id || op.State != WebComponentOperationRunning { webOperationsMu.Unlock() return nil } op.Phase = phase op.ProgressPercent = progressPercent op.Message = message copied := copyWebOperation(op) webOperationsMu.Unlock() _ = replaceWebOperationState(root, copied) return copied } func clampWebOperationProgress(value int) int { if value < 0 { return 0 } if value > 100 { return 100 } return value } func beginWebOperation(root string, kind WebComponentOperationKind) (*WebComponentOperationStatus, error) { if err := os.MkdirAll(root, 0o755); err != nil { return nil, err } now := time.Now().UTC() host, _ := os.Hostname() op := &WebComponentOperationStatus{ ID: fmt.Sprintf("%s-%d", kind, now.UnixNano()), Kind: kind, State: WebComponentOperationRunning, PID: os.Getpid(), Process: currentProcessToken(), Host: host, StartedAt: now.Format(time.RFC3339), } setInitialWebOperationProgress(op) webOperationsMu.Lock() defer webOperationsMu.Unlock() if existing := webOperations[root]; existing != nil && existing.State == WebComponentOperationRunning { return copyWebOperation(existing), ErrWebComponentOperationActive } if err := writeWebOperationState(root, op); err != nil { if os.IsExist(err) { existing := readWebOperationState(root) if isRecoverableWebOperation(existing) { if recoverErr := recoverStaleWebOperation(root, existing); recoverErr != nil { return copyWebOperation(existing), recoverErr } if err := writeWebOperationState(root, op); err != nil { if !os.IsExist(err) { return nil, err } } else { webOperations[root] = op return copyWebOperation(op), nil } } return copyWebOperation(existing), ErrWebComponentOperationActive } return nil, err } webOperations[root] = op return copyWebOperation(op), nil } func isRecoverableWebOperation(op *WebComponentOperationStatus) bool { if op == nil { return true } if op.malformed && webOperationLockAge(op) < webMalformedLockGrace { return false } if op.State != "" && op.State != WebComponentOperationRunning { return true } if op.PID <= 0 { return webOperationLockAge(op) >= webMalformedLockGrace } if op.Process != "" { if token := processToken(op.PID); token != "" { return op.Process != token } } if webOperationLockAge(op) >= webOperationStaleAge { return true } return !processIsRunning(op.PID) } func webOperationLockAge(op *WebComponentOperationStatus) time.Duration { if op == nil { return webOperationStaleAge } startedAt, err := time.Parse(time.RFC3339, op.StartedAt) if err == nil { return time.Since(startedAt) } if !op.lockMTime.IsZero() { return time.Since(op.lockMTime) } return webOperationStaleAge } func recoverStaleWebOperation(root string, op *WebComponentOperationStatus) error { operationLabel := "unknown Jellyfin Web operation" if op != nil && op.Kind != "" { operationLabel = fmt.Sprintf("stale Jellyfin Web %s operation", op.Kind) } err := fmt.Errorf("recovered %s lock after process restart", operationLabel) writeWebInstallError(root, err) id := "" if op != nil { id = op.ID } if !clearWebInstallStateForOperation(root, id) { return ErrWebComponentOperationActive } return nil } func processIsRunning(pid int) bool { if pid <= 0 { return false } if pid == os.Getpid() { return true } return processIsRunningBySignal(pid) } func processIsRunningBySignal(pid int) bool { err := syscall.Kill(pid, 0) return err == nil || errors.Is(err, syscall.EPERM) } func currentProcessToken() string { return processToken(os.Getpid()) } func processToken(pid int) string { if pid <= 0 { return "" } data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) if err != nil { return "" } stat := string(data) commEnd := strings.LastIndex(stat, ") ") if commEnd == -1 || commEnd+2 >= len(stat) { return "" } fields := strings.Fields(stat[commEnd+2:]) if len(fields) < 20 { return "" } return fields[19] } func finishWebOperation(root, id string, err error) *WebComponentOperationStatus { now := time.Now().UTC().Format(time.RFC3339) webOperationsMu.Lock() op := webOperations[root] var copied *WebComponentOperationStatus if op != nil && op.ID == id { op.CompletedAt = now if err != nil { op.State = WebComponentOperationFailed op.Error = err.Error() op.Message = "Jellyfin Web operation failed" } else { op.State = WebComponentOperationSucceeded op.Error = "" op.ProgressPercent = 100 if op.Kind == WebComponentOperationRemove { op.Message = "Jellyfin Web assets removed" } else { op.Message = "Jellyfin Web install complete" } } copied = copyWebOperation(op) } webOperationsMu.Unlock() if clearWebInstallStateForOperation(root, id) { if err != nil { writeWebInstallError(root, err) } else { clearWebInstallError(root) } } return copied } // CurrentWebOperation reports the install or remove operation in progress for // an install root, or nil when none is. Exported so a caller that has to // outlive an asynchronous operation — a test cleaning up the root it handed to // StartWebComponentRemove, for one — can wait for a terminal state rather than // deleting the directory out from under the goroutine still writing to it. func CurrentWebOperation(root string) *WebComponentOperationStatus { return currentWebOperation(root) } func currentWebOperation(root string) *WebComponentOperationStatus { webOperationsMu.Lock() op := copyWebOperation(webOperations[root]) webOperationsMu.Unlock() if op != nil { return op } op = readWebOperationState(root) if op != nil && isRecoverableWebOperation(op) { if err := recoverStaleWebOperation(root, op); err == nil { return nil } } return op } func copyWebOperation(op *WebComponentOperationStatus) *WebComponentOperationStatus { if op == nil { return nil } copied := *op return &copied } func readWebOperationState(root string) *WebComponentOperationStatus { path := filepath.Join(root, webInstallLock) info, statErr := os.Stat(path) data, err := os.ReadFile(path) if err != nil { return nil } var op WebComponentOperationStatus if err := json.Unmarshal(data, &op); err == nil && op.Kind != "" { if op.State == "" { op.State = WebComponentOperationRunning } if statErr == nil { op.lockMTime = info.ModTime() } return &op } legacy := strings.TrimSpace(string(data)) if legacy == "" { legacy = string(WebComponentOperationInstall) } kind := WebComponentOperationInstall if strings.Contains(legacy, "remov") { kind = WebComponentOperationRemove } return &WebComponentOperationStatus{ ID: "legacy", Kind: kind, State: WebComponentOperationRunning, StartedAt: lockStartedAt(info, statErr), Error: "", lockMTime: lockModifiedAt(info, statErr), malformed: true, } } func lockStartedAt(info os.FileInfo, err error) string { modified := lockModifiedAt(info, err) if modified.IsZero() { return "" } return modified.UTC().Format(time.RFC3339) } func lockModifiedAt(info os.FileInfo, err error) time.Time { if err != nil || info == nil { return time.Time{} } return info.ModTime() } func webComponentStatus(root, webDir, pinnedVersion, sourceURL string) WebComponentStatus { var statusError string normalizedRoot, err := normalizeWebInstallRoot(root) if err != nil { statusError = appendStatusError(statusError, err.Error()) normalizedRoot = strings.TrimSpace(root) if normalizedRoot == "" { normalizedRoot = config.DefaultJellyfinWebInstallDir } } webDir = strings.TrimSpace(webDir) if webDir == "" { webDir = ManagedWebInstallPath(normalizedRoot) } managedWebDir, err := normalizeManagedWebInstallPath(normalizedRoot, webDir) if err != nil { statusError = appendStatusError(statusError, err.Error()) } webDir = managedWebDir rawPinnedVersion := strings.TrimSpace(pinnedVersion) pinnedVersion = normalizeWebVersion(rawPinnedVersion) if pinnedVersion == "" { if rawPinnedVersion != "" { statusError = appendStatusError(statusError, fmt.Sprintf("invalid Jellyfin Web version %q", rawPinnedVersion)) } pinnedVersion = config.DefaultJellyfinWebVersion } sourceURL, err = normalizeWebSourceURL(sourceURL) if err != nil { statusError = appendStatusError(statusError, err.Error()) sourceURL = DefaultWebSourceURL } prereqs := CheckWebInstallerPrerequisites() installerReady := true for _, prereq := range prereqs { if !prereq.Available { installerReady = false break } } status := WebComponentStatus{ WebEnabled: true, WebState: WebComponentMissing, PinnedVersion: pinnedVersion, SourceURL: sourceURL, InstallRoot: normalizedRoot, InstallPath: webDir, InstallerReady: installerReady, Prerequisites: prereqs, LastError: statusError, } status.Operation = currentWebOperation(normalizedRoot) operationRunning := status.Operation != nil && status.Operation.State == WebComponentOperationRunning if operationRunning { switch status.Operation.Kind { case WebComponentOperationRemove: status.WebState = WebComponentRemoving default: status.WebState = WebComponentInstalling } } status.LastError = appendStatusError(status.LastError, readWebLastError(normalizedRoot)) if _, err := fs.Stat(os.DirFS(webDir), "index.html"); err != nil { if !operationRunning && status.LastError != "" { status.WebState = WebComponentFailed } return status } status.LicensePresent = filePresent(filepath.Join(webDir, "LICENSE")) status.ProvenancePresent = filePresent(filepath.Join(webDir, webMetadataFile)) && filePresent(filepath.Join(webDir, webSourceFile)) metadata, err := validateWebComponentDirectory(webDir) if err == nil { if !operationRunning { status.WebState = WebComponentInstalled } status.InstalledVersion = metadata.Version status.Tag = metadata.Tag status.CommitSHA = metadata.CommitSHA status.Checksum = metadata.Checksum status.InstalledAt = metadata.InstalledAt status.SourceURL = firstNonEmptyString(metadata.SourceURL, status.SourceURL) if !operationRunning && normalizeWebVersion(metadata.Version) != pinnedVersion { status.WebState = WebComponentUpdateAvailable } return status } status.LastError = appendStatusError(status.LastError, fmt.Sprintf("Jellyfin Web assets are not served because required provenance is missing or invalid: %v", err)) if !operationRunning { status.WebState = WebComponentFailed } return status } func normalizeWebVersion(version string) string { version = strings.TrimSpace(version) version = strings.TrimPrefix(version, "v") if !webVersionPattern.MatchString(version) { return "" } return version } func stringSetting(settings map[string]string, key, fallback string) string { if settings != nil { if value := strings.TrimSpace(settings[key]); value != "" { return value } } return fallback } func firstNonEmptyString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func appendStatusError(existing, next string) string { next = strings.TrimSpace(next) if next == "" { return strings.TrimSpace(existing) } if strings.TrimSpace(existing) == "" { return next } return existing + "; " + next } func commandOutput(ctx context.Context, dir, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir out, err := cmd.Output() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return "", fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), strings.TrimSpace(string(exitErr.Stderr))) } return "", err } return string(out), nil } func runWebInstallCommand(ctx context.Context, dir string, argv []string, stdin string) error { if len(argv) == 0 { return fmt.Errorf("empty command") } cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Dir = dir if stdin != "" { cmd.Stdin = strings.NewReader(stdin) } out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%s: %s", strings.Join(argv, " "), strings.TrimSpace(string(out))) } return nil } func copyDir(src, dst string) error { return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } rel, err := filepath.Rel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) if d.IsDir() { return os.MkdirAll(target, 0o755) } return copyFile(path, target) }) } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } out, err := os.Create(dst) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, in); err != nil { return err } return out.Close() } func directoryChecksum(root string) (string, error) { var files []string if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } rel, err := filepath.Rel(root, path) if err != nil { return err } files = append(files, filepath.ToSlash(rel)) return nil }); err != nil { return "", err } sort.Strings(files) hash := sha256.New() for _, rel := range files { if _, err := hash.Write([]byte(rel + "\x00")); err != nil { return "", err } file, err := os.Open(filepath.Join(root, filepath.FromSlash(rel))) if err != nil { return "", err } if _, err := io.Copy(hash, file); err != nil { file.Close() return "", err } if err := file.Close(); err != nil { return "", err } } return hex.EncodeToString(hash.Sum(nil)), nil } func writeWebMetadata(dir string, metadata WebComponentMetadata) error { data, err := json.MarshalIndent(metadata, "", " ") if err != nil { return err } data = append(data, '\n') return os.WriteFile(filepath.Join(dir, webMetadataFile), data, 0o644) } func readWebMetadata(dir string) (WebComponentMetadata, error) { var metadata WebComponentMetadata data, err := os.ReadFile(filepath.Join(dir, webMetadataFile)) if err != nil { return metadata, err } if err := json.Unmarshal(data, &metadata); err != nil { return metadata, err } return metadata, nil } func writeWebSourceFile(dir string, metadata WebComponentMetadata) error { body := fmt.Sprintf(`Jellyfin Web compatibility component Source: %s Tag: %s Commit: %s License: %s Build: %s Modified: %t Checksum: %s This component is separate from Silo's AGPL-licensed server code. It is installed only when an administrator explicitly requests Jellyfin-compatible web UI assets. `, metadata.SourceURL, metadata.Tag, metadata.CommitSHA, metadata.License, metadata.BuildCommand, metadata.Modified, metadata.Checksum) return os.WriteFile(filepath.Join(dir, webSourceFile), []byte(body), 0o644) } func activateWebComponent(root, version string) error { current := filepath.Join(root, "current") tmpLink := filepath.Join(root, ".current-next") _ = os.Remove(tmpLink) if err := os.Symlink(version, tmpLink); err != nil { return err } if err := os.Rename(tmpLink, current); err != nil { _ = os.Remove(tmpLink) return err } return nil } func writeWebOperationState(root string, op *WebComponentOperationStatus) error { if err := os.MkdirAll(root, 0o755); err != nil { return err } data, err := json.Marshal(op) if err != nil { return err } data = append(data, '\n') tmp, err := os.CreateTemp(root, ".install-lock-*") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Link(tmpName, filepath.Join(root, webInstallLock)); err != nil { return err } return nil } func replaceWebOperationState(root string, op *WebComponentOperationStatus) error { if err := os.MkdirAll(root, 0o755); err != nil { return err } data, err := json.Marshal(op) if err != nil { return err } data = append(data, '\n') tmp, err := os.CreateTemp(root, ".install-lock-*") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmpName, filepath.Join(root, webInstallLock)) } func clearWebInstallState(root string) { _ = os.Remove(filepath.Join(root, webInstallLock)) } func clearWebInstallStateForOperation(root, id string) bool { current := readWebOperationState(root) if current == nil { clearWebInstallState(root) return true } if id != "" && current.ID != id { return false } if id == "" && !isRecoverableWebOperation(current) { return false } clearWebInstallState(root) return true } func writeWebInstallError(root string, err error) { if err == nil { return } _ = os.WriteFile(filepath.Join(root, webLastError), []byte(err.Error()), 0o644) } func clearWebInstallError(root string) { _ = os.Remove(filepath.Join(root, webLastError)) } func readWebLastError(root string) string { data, err := os.ReadFile(filepath.Join(root, webLastError)) if err != nil { return "" } return strings.TrimSpace(string(data)) } func filePresent(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() }