package main import ( "bufio" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "strings" "time" "lukechampine.com/blake3" ) const releaseFilesDir = "release-files" // GitGud.io API constants const ( gitGudProjectID = "45219" gitGudBaseURL = "https://gitgud.io/api/v4" gitGudProjectURL = "https://gitgud.io/mike/mpv-manager" ) // Command-line flags var ( autoMode = flag.Bool("auto", false, "Run in non-interactive mode (use fetched defaults, exit on errors)") managerVersion = flag.String("manager-version", "", "MPV Manager version to use (required in auto mode)") outputFile = flag.String("output", "releases.json", "Output file path") quiet = flag.Bool("quiet", false, "Suppress progress output (useful for cron)") repoStats = flag.Bool("repo-stats", false, "Generate repo-stats.json instead of releases.json") statsOutput = flag.String("stats-output", "", "Output path for repo-stats.json (default: same directory as releases.json)") apiToken = flag.String("token", "", "Project Access Token for authenticated API requests (optional)") ) // GitHubRelease represents the response from GitHub's releases API type GitHubRelease struct { TagName string `json:"tag_name"` Name string `json:"name"` HTMLURL string `json:"html_url"` Assets []struct { Name string `json:"name"` URL string `json:"browser_download_url"` } `json:"assets"` } // GitLabRelease represents a GitLab release API response type GitLabRelease struct { TagName string `json:"tag_name"` Name string `json:"name"` Description string `json:"description"` ReleasedAt string `json:"released_at"` } type usedFile struct { url string filename string } var allUsedFiles []usedFile type Release struct { Version string `json:"version"` MpvVersion string `json:"mpv-version"` Date string `json:"date"` Windows struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` X8664v3 struct{ URL, BLAKE3 string } `json:"x86-64-v3"` Aarch64 struct{ URL, BLAKE3 string } `json:"aarch64"` } `json:"windows"` MacOS struct { ARMLatest struct{ URL, BLAKE3 string } `json:"arm-latest"` ARM15 struct{ URL, BLAKE3 string } `json:"arm-15"` Intel15 struct{ URL, BLAKE3 string } `json:"intel-15"` } `json:"macos"` FFmpeg struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` X8664v3 struct{ URL, BLAKE3 string } `json:"x86-64-v3"` Aarch64 struct{ URL, BLAKE3 string } `json:"aarch64"` AppVersion string `json:"app_version"` } `json:"ffmpeg"` UOSC struct { URL string `json:"url"` BLAKE3 string `json:"blake3"` ConfURL string `json:"conf_url"` ConfBLAKE3 string `json:"conf_blake3"` AppVersion string `json:"app_version"` } `json:"uosc"` ModernZ struct { ScriptURL string `json:"script_url"` // modernz.lua ScriptBLAKE3 string `json:"script_blake3"` FontURL string `json:"font_url"` // modernz-icons.ttf FontBLAKE3 string `json:"font_blake3"` ConfURL string `json:"conf_url"` // modernz.conf ConfBLAKE3 string `json:"conf_blake3"` AppVersion string `json:"app_version"` } `json:"modernz"` MPCQT struct { X8664 struct{ URL, BLAKE3 string } `json:"x86-64"` AppVersion string `json:"app_version"` } `json:"mpc-qt"` IINA struct { ARM struct{ URL, BLAKE3 string } `json:"arm"` Intel struct{ URL, BLAKE3 string } `json:"intel"` AppVersion string `json:"app_version"` } `json:"iina"` Manager struct { LinuxAMD64 struct{ URL, BLAKE3 string } `json:"linux-amd64"` LinuxARM64 struct{ URL, BLAKE3 string } `json:"linux-arm64"` WinX86_64 struct{ URL, BLAKE3 string } `json:"win-x86_64"` WinARM64 struct{ URL, BLAKE3 string } `json:"win-arm64"` MacosIntel struct{ URL, BLAKE3 string } `json:"macos-intel"` MacosARM struct{ URL, BLAKE3 string } `json:"macos-arm"` } `json:"manager"` } // GitLab API response types for repository statistics // GitLabProject represents the main project data from GitLab API type GitLabProject struct { StarCount int `json:"star_count"` ForksCount int `json:"forks_count"` LastActivityAt string `json:"last_activity_at"` CreatedAt string `json:"created_at"` License *GitLabLicense `json:"license"` Topics []string `json:"topics"` Description string `json:"description"` WebURL string `json:"web_url"` DefaultBranch string `json:"default_branch"` Visibility string `json:"visibility"` } // GitLabLicense represents license information from GitLab API type GitLabLicense struct { Key string `json:"key"` Name string `json:"name"` URL string `json:"url"` HTMLURL string `json:"html_url"` } // GitLabIssuesStats represents the issues statistics response from GitLab API type GitLabIssuesStats struct { Statistics struct { Counts struct { Opened int `json:"opened"` } `json:"counts"` } `json:"statistics"` } // GitLabPipeline represents a CI/CD pipeline from GitLab API type GitLabPipeline struct { Status string `json:"status"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Ref string `json:"ref"` SHA string `json:"sha"` } // RepoStatsLicense represents the license in the output JSON format type RepoStatsLicense struct { Key string `json:"key"` Name string `json:"name"` URL string `json:"url"` } // RepoStatsPipeline represents CI/CD pipeline info in output type RepoStatsPipeline struct { Status string `json:"status"` Ref string `json:"ref"` SHA string `json:"sha"` } // RepoStats is the combined output structure for repo-stats.json type RepoStats struct { StarCount int `json:"star_count"` ForksCount int `json:"forks_count"` OpenIssuesCount int `json:"open_issues_count"` CIStatus int `json:"ci_status"` CIPipeline *RepoStatsPipeline `json:"ci_pipeline,omitempty"` LastActivityAt string `json:"last_activity_at"` CreatedAt string `json:"created_at"` License *RepoStatsLicense `json:"license"` Topics []string `json:"topics"` Description string `json:"description"` WebURL string `json:"web_url"` DefaultBranch string `json:"default_branch"` Visibility string `json:"visibility"` FetchedAt string `json:"fetched_at"` } // fetchLatestRelease fetches the latest release from a GitHub repository func fetchLatestRelease(owner, repo string) (*GitHubRelease, error) { apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(apiURL) if err != nil { return nil, fmt.Errorf("failed to fetch release: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var release GitHubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &release, nil } // fetchLatestGitLabRelease fetches the latest release from gitgud.io func fetchLatestGitLabRelease(projectPath string) (*GitLabRelease, error) { // URL encode the project path: mike/mpv-manager -> mike%2Fmpv-manager encodedPath := url.PathEscape(projectPath) apiURL := fmt.Sprintf("https://gitgud.io/api/v4/projects/%s/releases", encodedPath) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(apiURL) if err != nil { return nil, fmt.Errorf("failed to fetch from GitLab API: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GitLab API returned status %d", resp.StatusCode) } var releases []GitLabRelease if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if len(releases) == 0 { return nil, fmt.Errorf("no releases found for project %s", projectPath) } return &releases[0], nil } // extractVersionFromTag removes 'v' prefix from tag names func extractVersionFromTag(tag string) string { return strings.TrimPrefix(tag, "v") } // parseWindowsBuildInfo extracts timestamp and hashes from Windows build release // zhongfly/mpv-winbuild tag format: "2026-04-09-ec4d50f" (date with dashes + MPV commit hash) // MPV filename: mpv-x86_64-20260409-git-ec4d50f.7z (date without dashes) // FFmpeg hash is extracted from release asset filenames func parseWindowsBuildInfo(release *GitHubRelease) (fullTag, timestamp, mpvHash, ffmpegHash string) { if release == nil { return "", "", "", "" } // Parse the tag: "2026-04-09-ec4d50f" → fullTag=tag, date="20260409", mpvHash="ec4d50f" fullTag = release.TagName tag := release.TagName // Extract MPV hash from the tag (last segment after the date) // Tag format: YYYY-MM-DD-HASH tagParts := strings.Split(tag, "-") if len(tagParts) >= 4 { // Date parts: tagParts[0]="2026", tagParts[1]="04", tagParts[2]="09" // Hash part: tagParts[3] (and potentially more if hash has dashes, but it shouldn't) timestamp = tagParts[0] + tagParts[1] + tagParts[2] // "20260409" mpvHash = strings.Join(tagParts[3:], "-") // "ec4d50f" } else { // Fallback: use raw tag as timestamp (for backward compatibility) timestamp = tag } // FFmpeg pattern: ffmpeg-x86_64-git-d3d0b7a5e.7z ffmpegRe := regexp.MustCompile(`ffmpeg-x86_64-git-([a-f0-9]+)\.7z$`) for _, asset := range release.Assets { if ffmpegHash == "" { if matches := ffmpegRe.FindStringSubmatch(asset.URL); len(matches) > 1 { ffmpegHash = matches[1] } } if ffmpegHash != "" { break } } return fullTag, timestamp, mpvHash, ffmpegHash } // fetchAllVersions fetches latest versions from all GitHub repositories func fetchAllVersions() (map[string]string, string, string, error) { versions := make(map[string]string) var winTimestamp string println("\n--- Fetching Latest Versions from GitHub ---") // MPV print(" MPV... ") if release, err := fetchLatestRelease("mpv-player", "mpv"); err != nil { print("ERROR: %v\n", err) versions["mpv"] = "0.41.0" } else { versions["mpv"] = extractVersionFromTag(release.TagName) println(versions["mpv"]) } // uOSC print(" uOSC... ") if release, err := fetchLatestRelease("tomasklaen", "uosc"); err != nil { print("ERROR: %v\n", err) versions["uosc"] = "5.12.0" } else { versions["uosc"] = release.TagName // uOSC uses tags without 'v' prefix println(versions["uosc"]) } // ModernZ print(" ModernZ... ") if release, err := fetchLatestRelease("Samillion", "ModernZ"); err != nil { print("ERROR: %v\n", err) versions["modernz"] = "0.3.0" } else { versions["modernz"] = extractVersionFromTag(release.TagName) println(versions["modernz"]) } // MPC-QT print(" MPC-QT... ") if release, err := fetchLatestRelease("mpc-qt", "mpc-qt"); err != nil { print("ERROR: %v\n", err) versions["mpcqt"] = "26.01" } else { versions["mpcqt"] = extractVersionFromTag(release.TagName) println(versions["mpcqt"]) } // IINA print(" IINA... ") if release, err := fetchLatestRelease("iina", "iina"); err != nil { print("ERROR: %v\n", err) versions["iina"] = "1.4.1" } else { versions["iina"] = extractVersionFromTag(release.TagName) println(versions["iina"]) } // Windows builds (zhongfly/mpv-winbuild) print(" Windows builds... ") var winMpvHash, winFFmpegHash, winFullTag string if release, err := fetchLatestRelease("zhongfly", "mpv-winbuild"); err != nil { print("ERROR: %v\n", err) winTimestamp = time.Now().Format("20060102") winFullTag = winTimestamp winMpvHash = "unknown" winFFmpegHash = "unknown" } else { winFullTag, winTimestamp, winMpvHash, winFFmpegHash = parseWindowsBuildInfo(release) print("%s (mpv: %s, ffmpeg: %s)\n", winTimestamp, winMpvHash, winFFmpegHash) } versions["win_mpv_hash"] = winMpvHash versions["win_ffmpeg_hash"] = winFFmpegHash versions["win_full_tag"] = winFullTag return versions, winTimestamp, winMpvHash, nil } func extractFilenameFromURL(url string) string { parts := strings.Split(url, "/") return parts[len(parts)-1] } func getLocalFilePath(url string) string { filename := extractFilenameFromURL(url) return filepath.Join(releaseFilesDir, filename) } // getCachedURL reads the URL from a .url sidecar file for a cached file func getCachedURL(localPath string) string { urlFile := localPath + ".url" data, err := os.ReadFile(urlFile) if err != nil { return "" } return strings.TrimSpace(string(data)) } // setCachedURL writes the URL to a .url sidecar file for a cached file func setCachedURL(localPath, url string) error { urlFile := localPath + ".url" return os.WriteFile(urlFile, []byte(url), 0644) } func downloadFileIfNotExists(url string) (string, error) { localPath := getLocalFilePath(url) // Check if file exists AND the URL matches (to handle version changes) if _, err := os.Stat(localPath); err == nil { cachedURL := getCachedURL(localPath) if cachedURL == url { print(" Using cached file: %s\n", extractFilenameFromURL(url)) return localPath, nil } // URL changed (e.g., new version), need to re-download print(" Cache outdated (URL changed), re-downloading: %s\n", extractFilenameFromURL(url)) os.Remove(localPath) os.Remove(localPath + ".url") } print(" Downloading: %s\n", url) print(" Saving to: %s\n", extractFilenameFromURL(url)) resp, err := http.Get(url) if err != nil { return "", fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("HTTP %d", resp.StatusCode) } if err := os.MkdirAll(releaseFilesDir, 0755); err != nil { return "", fmt.Errorf("failed to create directory: %w", err) } out, err := os.Create(localPath) if err != nil { return "", fmt.Errorf("failed to create file: %w", err) } defer out.Close() if _, err := io.Copy(out, resp.Body); err != nil { return "", fmt.Errorf("failed to save download: %w", err) } // Save the URL to sidecar file for cache validation if err := setCachedURL(localPath, url); err != nil { print(" Warning: Failed to save cache metadata: %v\n", err) } return localPath, nil } func getUsedFiles(release Release) []usedFile { var files []usedFile addFile := func(url string) { files = append(files, usedFile{ url: url, filename: extractFilenameFromURL(url), }) } files = append(files, usedFile{ url: release.UOSC.ConfURL, filename: extractFilenameFromURL(release.UOSC.ConfURL), }) files = append(files, usedFile{ url: release.ModernZ.ConfURL, filename: extractFilenameFromURL(release.ModernZ.ConfURL), }) addFile(release.Windows.X8664.URL) addFile(release.Windows.X8664v3.URL) addFile(release.Windows.Aarch64.URL) addFile(release.FFmpeg.X8664.URL) addFile(release.FFmpeg.X8664v3.URL) addFile(release.FFmpeg.Aarch64.URL) addFile(release.MacOS.ARMLatest.URL) addFile(release.MacOS.ARM15.URL) addFile(release.MacOS.Intel15.URL) addFile(release.UOSC.URL) addFile(release.ModernZ.ScriptURL) addFile(release.ModernZ.FontURL) addFile(release.MPCQT.X8664.URL) addFile(release.IINA.ARM.URL) addFile(release.IINA.Intel.URL) addFile(release.Manager.LinuxAMD64.URL) addFile(release.Manager.LinuxARM64.URL) addFile(release.Manager.WinX86_64.URL) addFile(release.Manager.WinARM64.URL) addFile(release.Manager.MacosIntel.URL) addFile(release.Manager.MacosARM.URL) return files } func cleanupUnusedFiles(usedFiles []usedFile) { entries, err := os.ReadDir(releaseFilesDir) if err != nil { if os.IsNotExist(err) { return } if !*quiet { fmt.Printf("Error reading release-files directory: %v\n", err) } return } if len(entries) == 0 { if !*quiet { fmt.Println("\nNo files in release-files directory to clean up.") } return } usedFilenames := make(map[string]bool) for _, file := range usedFiles { usedFilenames[file.filename] = true } var unusedFiles []string for _, entry := range entries { if entry.IsDir() { continue } // Skip .url sidecar files - they're cleaned up with their main file if strings.HasSuffix(entry.Name(), ".url") { continue } if !usedFilenames[entry.Name()] { unusedFiles = append(unusedFiles, entry.Name()) } } if len(unusedFiles) == 0 { if !*quiet { fmt.Println("\nAll files in release-files are in use.") } return } // In auto mode, remove unused files silently if *autoMode { for _, file := range unusedFiles { filePath := filepath.Join(releaseFilesDir, file) os.Remove(filePath) // Also remove .url sidecar file if it exists os.Remove(filePath + ".url") } return } // Interactive mode - show files and ask fmt.Printf("\nFound %d unused file(s) in release-files:\n", len(unusedFiles)) for _, file := range unusedFiles { fmt.Printf(" - %s\n", file) } fmt.Printf("\nRemove unused files? [Y/n]: ") reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') if err != nil { return } input = strings.TrimSpace(input) if strings.ToLower(input) == "n" { fmt.Println("Keeping unused files.") return } for _, file := range unusedFiles { filePath := filepath.Join(releaseFilesDir, file) if err := os.Remove(filePath); err != nil { fmt.Printf("Error removing %s: %v\n", file, err) } else { fmt.Printf("Removed: %s\n", file) } // Also remove .url sidecar file if it exists os.Remove(filePath + ".url") } fmt.Println("Cleanup complete.") } // print outputs text unless quiet mode is enabled func print(format string, args ...interface{}) { if !*quiet { fmt.Printf(format, args...) } } // println outputs a line unless quiet mode is enabled func println(args ...interface{}) { if !*quiet { fmt.Println(args...) } } // --- GitLab API functions for repository statistics --- // mapPipelineStatus converts GitLab pipeline status string to numeric code func mapPipelineStatus(status string) int { switch status { case "success": return 0 case "pending": return 1 case "running": return 2 case "failed": return 3 case "canceled": return 4 case "skipped": return 5 default: return -1 } } // doGitLabRequest performs an HTTP request to GitLab API with optional authentication func doGitLabRequest(url string) (*http.Response, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } if *apiToken != "" { req.Header.Set("Authorization", "Bearer "+*apiToken) } client := &http.Client{Timeout: 10 * time.Second} return client.Do(req) } // fetchGitLabProject fetches main project information from GitLab API func fetchGitLabProject() (*GitLabProject, error) { url := fmt.Sprintf("%s/projects/%s?license=true", gitGudBaseURL, gitGudProjectID) print(" Fetching project info... ") resp, err := doGitLabRequest(url) if err != nil { print("ERROR: %v\n", err) return nil, fmt.Errorf("failed to fetch project: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { print("HTTP %d\n", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var project GitLabProject if err := json.NewDecoder(resp.Body).Decode(&project); err != nil { print("Parse error: %v\n", err) return nil, fmt.Errorf("failed to parse response: %w", err) } // Ensure WebURL is set if project.WebURL == "" { project.WebURL = gitGudProjectURL } println(project.StarCount, "stars,", project.ForksCount, "forks") return &project, nil } // fetchGitLabIssuesStats fetches issues statistics from GitLab API func fetchGitLabIssuesStats() (*GitLabIssuesStats, error) { url := fmt.Sprintf("%s/projects/%s/issues_statistics", gitGudBaseURL, gitGudProjectID) print(" Fetching issues stats... ") resp, err := doGitLabRequest(url) if err != nil { print("ERROR: %v\n", err) return nil, fmt.Errorf("failed to fetch issues stats: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { print("HTTP %d\n", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var stats GitLabIssuesStats if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { print("Parse error: %v\n", err) return nil, fmt.Errorf("failed to parse response: %w", err) } println(stats.Statistics.Counts.Opened, "open issues") return &stats, nil } // fetchGitLabPipelineStatus fetches the latest pipeline status from GitLab API func fetchGitLabPipelineStatus() (*GitLabPipeline, error) { url := fmt.Sprintf("%s/projects/%s/pipelines?per_page=1", gitGudBaseURL, gitGudProjectID) print(" Fetching pipeline status... ") resp, err := doGitLabRequest(url) if err != nil { print("ERROR: %v\n", err) return nil, fmt.Errorf("failed to fetch pipeline: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { print("HTTP %d\n", resp.StatusCode) return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var pipelines []GitLabPipeline if err := json.NewDecoder(resp.Body).Decode(&pipelines); err != nil { print("Parse error: %v\n", err) return nil, fmt.Errorf("failed to parse response: %w", err) } if len(pipelines) == 0 { println("no pipelines found") return nil, fmt.Errorf("no pipelines found") } println(pipelines[0].Status, "-", pipelines[0].Ref) return &pipelines[0], nil } // generateRepoStats creates a RepoStats structure with fetched data // Falls back to defaults for any data that couldn't be fetched func generateRepoStats() *RepoStats { stats := &RepoStats{ StarCount: 0, ForksCount: 0, OpenIssuesCount: 0, CIStatus: -1, LastActivityAt: "", CreatedAt: "", License: nil, Topics: []string{}, Description: "", WebURL: gitGudProjectURL, DefaultBranch: "master", Visibility: "public", FetchedAt: time.Now().UTC().Format(time.RFC3339), } // Fetch project info if project, err := fetchGitLabProject(); err == nil { stats.StarCount = project.StarCount stats.ForksCount = project.ForksCount stats.LastActivityAt = project.LastActivityAt stats.CreatedAt = project.CreatedAt stats.Topics = project.Topics stats.Description = project.Description stats.WebURL = project.WebURL stats.DefaultBranch = project.DefaultBranch stats.Visibility = project.Visibility // Process license if project.License != nil { licenseURL := project.License.URL if licenseURL == "" && project.License.HTMLURL != "" { licenseURL = project.License.HTMLURL } stats.License = &RepoStatsLicense{ Key: project.License.Key, Name: project.License.Name, URL: licenseURL, } } } else { print(" Warning: Using defaults for project info\n") } // Fetch issues stats if issues, err := fetchGitLabIssuesStats(); err == nil { stats.OpenIssuesCount = issues.Statistics.Counts.Opened } else { print(" Warning: Using 0 for open issues count\n") } // Fetch pipeline status if pipeline, err := fetchGitLabPipelineStatus(); err == nil { stats.CIStatus = mapPipelineStatus(pipeline.Status) stats.CIPipeline = &RepoStatsPipeline{ Status: pipeline.Status, Ref: pipeline.Ref, SHA: pipeline.SHA, } } else { print(" Warning: Using -1 for CI status\n") } return stats } // writeRepoStatsFile writes the RepoStats to a JSON file func writeRepoStatsFile(stats *RepoStats, outputPath string) error { // Ensure directory exists dir := filepath.Dir(outputPath) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } } data, err := json.MarshalIndent(stats, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %w", err) } if err := os.WriteFile(outputPath, data, 0644); err != nil { return fmt.Errorf("failed to write file: %w", err) } return nil } // computeHashForURL downloads a file and computes its BLAKE3 hash // In auto mode, returns error on failure; in interactive mode, returns default hash func computeHashForURL(prompt, url, defaultHash string) (string, error) { filePath, err := downloadFileIfNotExists(url) if err != nil { if *autoMode { return "", fmt.Errorf("failed to download %s: %w", url, err) } print(" Error downloading: %v\n", err) print(" Using default value: %s\n", defaultHash) return defaultHash, nil } hash, err := computeBLAKE3(filePath) if err != nil { if *autoMode { return "", fmt.Errorf("failed to compute hash for %s: %w", url, err) } print(" Error computing hash: %v\n", err) print(" Using default value: %s\n", defaultHash) return defaultHash, nil } allUsedFiles = append(allUsedFiles, usedFile{ url: url, filename: extractFilenameFromURL(url), }) print(" Computed BLAKE3: %s\n", hash) return hash, nil } // runRepoStatsMode handles the -repo-stats flag to generate repository statistics func runRepoStatsMode() { if !*quiet { fmt.Println("MPV.Rocks Manager - Repository Stats Generator") fmt.Println("==============================================") fmt.Println("Fetching repository statistics from GitGud.io") fmt.Println() } // Generate stats (with graceful error handling) stats := generateRepoStats() // Determine output path outputPath := *statsOutput if outputPath == "" { // Default: same directory as releases.json outputPath = filepath.Join(filepath.Dir(*outputFile), "repo-stats.json") // If releases.json path has no directory, use current directory if outputPath == "repo-stats.json" || outputPath == "./repo-stats.json" { outputPath = "repo-stats.json" } } // Write to file if err := writeRepoStatsFile(stats, outputPath); err != nil { fmt.Fprintf(os.Stderr, "Error writing repo-stats.json: %v\n", err) os.Exit(1) } print("\nāœ“ Generated %s\n", outputPath) print(" Stars: %d, Forks: %d, Open Issues: %d, CI Status: %d\n", stats.StarCount, stats.ForksCount, stats.OpenIssuesCount, stats.CIStatus) } func printUsage() { fmt.Fprintf(os.Stderr, "MPV.Rocks Manager - Info Generator\n") fmt.Fprintf(os.Stderr, "===================================\n\n") fmt.Fprintf(os.Stderr, "This tool generates JSON files for https://mpv.rocks\n\n") fmt.Fprintf(os.Stderr, "USAGE:\n") fmt.Fprintf(os.Stderr, " generate-info [OPTIONS] Generate releases.json (default)\n") fmt.Fprintf(os.Stderr, " generate-info -repo-stats [OPTS] Generate repo-stats.json\n\n") fmt.Fprintf(os.Stderr, "MODES:\n") fmt.Fprintf(os.Stderr, " (default) Fetch release versions from GitHub, download binaries,\n") fmt.Fprintf(os.Stderr, " compute BLAKE3 hashes, generate releases.json\n") fmt.Fprintf(os.Stderr, " -repo-stats Fetch repository statistics from GitGud.io API\n") fmt.Fprintf(os.Stderr, " (stars, forks, issues, CI status) and generate repo-stats.json\n\n") fmt.Fprintf(os.Stderr, "OPTIONS:\n") flag.PrintDefaults() fmt.Fprintf(os.Stderr, "\nEXAMPLES:\n") fmt.Fprintf(os.Stderr, " # Generate releases.json (interactive mode)\n") fmt.Fprintf(os.Stderr, " generate-info\n\n") fmt.Fprintf(os.Stderr, " # Generate releases.json (auto mode for cron)\n") fmt.Fprintf(os.Stderr, " generate-info -auto -quiet\n\n") fmt.Fprintf(os.Stderr, " # Generate repo-stats.json\n") fmt.Fprintf(os.Stderr, " generate-info -repo-stats\n\n") fmt.Fprintf(os.Stderr, " # Generate repo-stats.json with custom output path\n") fmt.Fprintf(os.Stderr, " generate-info -repo-stats -stats-output /var/www/api/repo-stats.json\n\n") fmt.Fprintf(os.Stderr, " # Generate repo-stats.json with API token\n") fmt.Fprintf(os.Stderr, " generate-info -repo-stats -token glpat-xxxxxxxxxxxx -quiet\n") } func main() { flag.Usage = printUsage flag.Parse() // Handle repo-stats mode if *repoStats { runRepoStatsMode() return } if !*quiet { fmt.Println("MPV.Rocks Manager - Releases JSON Generator") fmt.Println("============================================") fmt.Println("This generates a releases.json file for https://mpv.rocks") if *autoMode { fmt.Println("Running in AUTO mode (non-interactive)") } else { fmt.Println("Version information is fetched automatically from GitHub.") } fmt.Println() } // In auto mode, manager version can be fetched from GitLab or provided via flag var managerVer string if *autoMode { // Try to fetch manager version from GitLab API print(" Fetching MPV Manager version from GitLab... ") if gitlabRelease, err := fetchLatestGitLabRelease("mike/mpv-manager"); err != nil { print("ERROR: %v\n", err) if *managerVersion == "" { fmt.Fprintln(os.Stderr, "Error: Failed to fetch manager version from GitLab and --manager-version not provided") fmt.Fprintln(os.Stderr, "Usage: generate-info --auto --manager-version 1.0.0") os.Exit(1) } managerVer = *managerVersion } else { managerVer = extractVersionFromTag(gitlabRelease.TagName) println(managerVer) } } // Fetch all versions from GitHub versions, winTimestamp, _, err := fetchAllVersions() if err != nil { if *autoMode { fmt.Fprintf(os.Stderr, "Error fetching versions: %v\n", err) os.Exit(1) } fmt.Printf("Error fetching versions: %v\n", err) os.Exit(1) } release := Release{} var mpvVersion, releaseDate, winMpvHash, winFFmpegHash, winFullTag string var uoscVersion, modernZVersion, mpcqtVersion, iinaVersion string if *autoMode { // Use fetched defaults automatically mpvVersion = versions["mpv"] releaseDate = time.Now().Format("2006-01-02") winFullTag = versions["win_full_tag"] winMpvHash = versions["win_mpv_hash"] winFFmpegHash = versions["win_ffmpeg_hash"] uoscVersion = versions["uosc"] modernZVersion = versions["modernz"] mpcqtVersion = versions["mpcqt"] iinaVersion = versions["iina"] } else { // Interactive mode - prompt for versions reader := bufio.NewReader(os.Stdin) println("\n--- Confirm/Override Versions ---") println("(Press Enter to use the fetched version)") mpvVersion = promptString(reader, "MPV Version", versions["mpv"]) releaseDate = promptString(reader, "Release Date", time.Now().Format("2006-01-02")) fmt.Println("\n--- Windows & FFmpeg Builds ---") winFullTag = promptString(reader, "Windows Build Full Tag", versions["win_full_tag"]) winTimestamp = promptString(reader, "Windows Build Timestamp (YYYYMMDD)", winTimestamp) winMpvHash = promptString(reader, "Windows MPV Commit Hash", versions["win_mpv_hash"]) winFFmpegHash = promptString(reader, "FFmpeg Commit Hash", versions["win_ffmpeg_hash"]) uoscVersion = promptString(reader, "uOSC Version", versions["uosc"]) modernZVersion = promptString(reader, "ModernZ Version", versions["modernz"]) mpcqtVersion = promptString(reader, "MPC-QT Version", versions["mpcqt"]) iinaVersion = promptString(reader, "IINA Version", versions["iina"]) fmt.Println("\n--- MPV Manager ---") if *managerVersion != "" { managerVer = *managerVersion fmt.Printf("MPV Manager Version: %s (from flag)\n", managerVer) } else { // Try to fetch from GitLab print(" Fetching MPV Manager version from GitLab... ") if gitlabRelease, err := fetchLatestGitLabRelease("mike/mpv-manager"); err != nil { print("ERROR: %v\n", err) managerVer = promptString(reader, "MPV Manager Version", "1.0.0") } else { managerVer = extractVersionFromTag(gitlabRelease.TagName) println(managerVer) } } } // Now process all items print("\n--- Processing URLs and Computing Hashes ---\n") release.Version = managerVer release.MpvVersion = mpvVersion release.Date = releaseDate // Helper to compute hash and exit on error in auto mode hashOrExit := func(prompt, url, defaultHash string) string { hash, err := computeHashForURL(prompt, url, defaultHash) if err != nil { if *autoMode { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } return defaultHash } return hash } // Generate Windows URLs print("Computing hashes for Windows binaries...\n") release.Windows.X8664.URL = generateWindowsURL(winFullTag, winTimestamp, "x86_64", winMpvHash) release.Windows.X8664.BLAKE3 = hashOrExit("Windows x86-64", release.Windows.X8664.URL, "blake3:HASH") release.Windows.X8664v3.URL = generateWindowsURL(winFullTag, winTimestamp, "x86_64-v3", winMpvHash) release.Windows.X8664v3.BLAKE3 = hashOrExit("Windows x86-64-v3", release.Windows.X8664v3.URL, "blake3:HASH") release.Windows.Aarch64.URL = generateWindowsURL(winFullTag, winTimestamp, "aarch64", winMpvHash) release.Windows.Aarch64.BLAKE3 = hashOrExit("Windows aarch64", release.Windows.Aarch64.URL, "blake3:HASH") // Generate FFmpeg URLs (uses different hash than Windows MPV builds) print("Computing hashes for FFmpeg binaries...\n") release.FFmpeg.X8664.URL = generateFFmpegURL(winFullTag, winTimestamp, "x86_64", winFFmpegHash) release.FFmpeg.X8664.BLAKE3 = hashOrExit("FFmpeg x86-64", release.FFmpeg.X8664.URL, "blake3:HASH") release.FFmpeg.X8664v3.URL = generateFFmpegURL(winFullTag, winTimestamp, "x86_64-v3", winFFmpegHash) release.FFmpeg.X8664v3.BLAKE3 = hashOrExit("FFmpeg x86-64-v3", release.FFmpeg.X8664v3.URL, "blake3:HASH") release.FFmpeg.Aarch64.URL = generateFFmpegURL(winFullTag, winTimestamp, "aarch64", winFFmpegHash) release.FFmpeg.Aarch64.BLAKE3 = hashOrExit("FFmpeg aarch64", release.FFmpeg.Aarch64.URL, "blake3:HASH") release.FFmpeg.AppVersion = fmt.Sprintf("%s-%s", winTimestamp, winFFmpegHash) // macOS binaries print("Computing hashes for macOS binaries...\n") release.MacOS.ARMLatest.URL = generateMacOSURL(mpvVersion, "macos", "26-arm") release.MacOS.ARMLatest.BLAKE3 = hashOrExit("macOS ARM Latest", release.MacOS.ARMLatest.URL, "blake3:HASH") release.MacOS.ARM15.URL = generateMacOSURL(mpvVersion, "macos", "15-arm") release.MacOS.ARM15.BLAKE3 = hashOrExit("macOS ARM 15", release.MacOS.ARM15.URL, "blake3:HASH") release.MacOS.Intel15.URL = generateMacOSURL(mpvVersion, "macos", "15-intel") release.MacOS.Intel15.BLAKE3 = hashOrExit("macOS Intel 15", release.MacOS.Intel15.URL, "blake3:HASH") // uOSC print("Computing hashes for uOSC...\n") release.UOSC.URL = generateUOSCURL(uoscVersion) release.UOSC.BLAKE3 = hashOrExit("uOSC", release.UOSC.URL, "blake3:HASH") release.UOSC.ConfURL = fmt.Sprintf("https://github.com/tomasklaen/uosc/releases/download/%s/uosc.conf", uoscVersion) release.UOSC.ConfBLAKE3 = hashOrExit("uOSC Config", release.UOSC.ConfURL, "blake3:HASH") release.UOSC.AppVersion = uoscVersion // ModernZ print("Computing hashes for ModernZ...\n") release.ModernZ.ScriptURL = generateModernZScriptURL(modernZVersion) release.ModernZ.ScriptBLAKE3 = hashOrExit("ModernZ Script", release.ModernZ.ScriptURL, "blake3:HASH") release.ModernZ.FontURL = generateModernZFontURL(modernZVersion) release.ModernZ.FontBLAKE3 = hashOrExit("ModernZ Font", release.ModernZ.FontURL, "blake3:HASH") release.ModernZ.ConfURL = fmt.Sprintf("https://github.com/Samillion/ModernZ/releases/download/v%s/modernz.conf", modernZVersion) release.ModernZ.ConfBLAKE3 = hashOrExit("ModernZ Config", release.ModernZ.ConfURL, "blake3:HASH") release.ModernZ.AppVersion = modernZVersion // MPC-QT print("Computing hashes for MPC-QT...\n") release.MPCQT.X8664.URL = generateMPCQTURL(mpcqtVersion) release.MPCQT.X8664.BLAKE3 = hashOrExit("MPC-QT x86-64", release.MPCQT.X8664.URL, "blake3:HASH") release.MPCQT.AppVersion = mpcqtVersion // IINA print("Computing hashes for IINA...\n") release.IINA.ARM.URL = generateIINAURL(iinaVersion) release.IINA.ARM.BLAKE3 = hashOrExit("IINA ARM", release.IINA.ARM.URL, "blake3:HASH") release.IINA.Intel.URL = generateIINAURL(iinaVersion) release.IINA.Intel.BLAKE3 = hashOrExit("IINA Intel", release.IINA.Intel.URL, "blake3:HASH") release.IINA.AppVersion = iinaVersion // Manager - generate all platforms (using GitLab Generic Package Registry for direct downloads) // URL format: https://gitgud.io/api/v4/projects/mike%2Fmpv-manager/packages/generic/mpv-manager/v1.0.0/mpv-manager-linux-amd64 print("Computing hashes for MPV Manager...\n") release.Manager.LinuxAMD64.URL = fmt.Sprintf("https://gitgud.io/api/v4/projects/mike%%2Fmpv-manager/packages/generic/mpv-manager/v%s/mpv-manager-linux-amd64", managerVer) release.Manager.LinuxAMD64.BLAKE3 = hashOrExit("Manager Linux AMD64", release.Manager.LinuxAMD64.URL, "blake3:HASH") release.Manager.LinuxARM64.URL = fmt.Sprintf("https://gitgud.io/api/v4/projects/mike%%2Fmpv-manager/packages/generic/mpv-manager/v%s/mpv-manager-linux-arm64", managerVer) release.Manager.LinuxARM64.BLAKE3 = hashOrExit("Manager Linux ARM64", release.Manager.LinuxARM64.URL, "blake3:HASH") release.Manager.WinX86_64.URL = fmt.Sprintf("https://gitgud.io/api/v4/projects/mike%%2Fmpv-manager/packages/generic/mpv-manager/v%s/mpv-manager-win-x86_64.exe", managerVer) release.Manager.WinX86_64.BLAKE3 = hashOrExit("Manager Windows x86-64", release.Manager.WinX86_64.URL, "blake3:HASH") release.Manager.WinARM64.URL = fmt.Sprintf("https://gitgud.io/api/v4/projects/mike%%2Fmpv-manager/packages/generic/mpv-manager/v%s/mpv-manager-win-arm64.exe", managerVer) release.Manager.WinARM64.BLAKE3 = hashOrExit("Manager Windows ARM64", release.Manager.WinARM64.URL, "blake3:HASH") release.Manager.MacosIntel.URL = fmt.Sprintf("https://gitgud.io/api/v4/projects/mike%%2Fmpv-manager/packages/generic/mpv-manager/v%s/mpv-manager-macos-intel", managerVer) release.Manager.MacosIntel.BLAKE3 = hashOrExit("Manager macOS Intel", release.Manager.MacosIntel.URL, "blake3:HASH") release.Manager.MacosARM.URL = fmt.Sprintf("https://gitgud.io/api/v4/projects/mike%%2Fmpv-manager/packages/generic/mpv-manager/v%s/mpv-manager-macos-arm", managerVer) release.Manager.MacosARM.BLAKE3 = hashOrExit("Manager macOS ARM", release.Manager.MacosARM.URL, "blake3:HASH") data, err := json.MarshalIndent(release, "", "\t") if err != nil { fmt.Fprintf(os.Stderr, "Error generating JSON: %v\n", err) os.Exit(1) } if err := os.WriteFile(*outputFile, []byte(data), 0644); err != nil { fmt.Fprintf(os.Stderr, "Error writing to %s: %v\n", *outputFile, err) os.Exit(1) } print("\nāœ“ Generated %s with %d bytes\n", *outputFile, len(data)) cleanupUnusedFiles(allUsedFiles) } func computeBLAKE3(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", err } defer file.Close() hash := blake3.New(32, nil) if _, err := io.Copy(hash, file); err != nil { return "", err } return "blake3:" + hex.EncodeToString(hash.Sum(nil)), nil } func generateWindowsURL(fullTag, timestamp, arch, hash string) string { return fmt.Sprintf("https://github.com/zhongfly/mpv-winbuild/releases/download/%s/mpv-%s-%s-git-%s.7z", fullTag, arch, timestamp, hash) } // generateFFmpegURL builds a download URL for FFmpeg builds. // The timestamp parameter is unused but kept for API symmetry with generateWindowsURL. func generateFFmpegURL(fullTag, timestamp, arch, hash string) string { return fmt.Sprintf("https://github.com/zhongfly/mpv-winbuild/releases/download/%s/ffmpeg-%s-git-%s.7z", fullTag, arch, hash) } func generateMacOSURL(version, osType, arch string) string { return fmt.Sprintf("https://github.com/mpv-player/mpv/releases/download/v%s/mpv-v%s-%s-%s.zip", version, version, osType, arch) } func generateIINAURL(version string) string { return fmt.Sprintf("https://github.com/iina/iina/releases/download/v%s/IINA.v%s.dmg", version, version) } func generateUOSCURL(version string) string { return fmt.Sprintf("https://github.com/tomasklaen/uosc/releases/download/%s/uosc.zip", version) } func generateModernZScriptURL(version string) string { return fmt.Sprintf("https://github.com/Samillion/ModernZ/releases/download/v%s/modernz.lua", version) } func generateModernZFontURL(version string) string { return fmt.Sprintf("https://github.com/Samillion/ModernZ/releases/download/v%s/modernz-icons.ttf", version) } func generateMPCQTURL(version string) string { return fmt.Sprintf("https://github.com/mpc-qt/mpc-qt/releases/download/v%s/mpc-qt-win-x64-%s-installer.exe", version, version) } func generateInstallerURL(version, osType, arch, ext string) string { return fmt.Sprintf("https://gitgud.io/mike/mpv-manager/-/releases/%s/downloads/install-mpv-%s-%s%s", version, osType, arch, ext) } func promptString(reader *bufio.Reader, prompt, defaultVal string) string { fmt.Printf("%s [%s]: ", prompt, defaultVal) input, err := reader.ReadString('\n') if err != nil { return defaultVal } input = strings.TrimSpace(input) if input == "" { return defaultVal } return input }