support for homebrew ffmpeg on macos (#649)

* support for homebrew ffmpeg on macos

* Add build instructions

* Revise README

* Detect homebrew and install ffmpeg with homebrew

---------

Co-authored-by: afkarxyz <mzamzamafkarhadiq@gmail.com>
This commit is contained in:
sh4tteredd
2026-03-24 15:00:31 +01:00
committed by GitHub
parent eb468b16df
commit ae2e4eb155
3 changed files with 156 additions and 15 deletions
+35 -4
View File
@@ -1041,10 +1041,6 @@ func (a *App) IsFFprobeInstalled() (bool, error) {
return backend.IsFFprobeInstalled() return backend.IsFFprobeInstalled()
} }
func (a *App) GetFFmpegPath() (string, error) {
return backend.GetFFmpegPath()
}
type DownloadFFmpegRequest struct{} type DownloadFFmpegRequest struct{}
type DownloadFFmpegResponse struct { type DownloadFFmpegResponse struct {
@@ -1073,6 +1069,41 @@ func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
} }
} }
func (a *App) GetBrewPath() string {
return backend.GetBrewPath()
}
func (a *App) IsBrewFFmpegInstalled() (bool, error) {
return backend.IsBrewFFmpegInstalled()
}
type InstallFFmpegWithBrewResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
func (a *App) InstallFFmpegWithBrew() InstallFFmpegWithBrewResponse {
runtime.EventsEmit(a.ctx, "ffmpeg:status", "Installing FFmpeg via Homebrew...")
err := backend.InstallFFmpegWithBrew(func(progress int, status string) {
runtime.EventsEmit(a.ctx, "ffmpeg:progress", progress)
runtime.EventsEmit(a.ctx, "ffmpeg:status", status)
})
if err != nil {
runtime.EventsEmit(a.ctx, "ffmpeg:status", "failed")
return InstallFFmpegWithBrewResponse{
Success: false,
Error: err.Error(),
}
}
runtime.EventsEmit(a.ctx, "ffmpeg:status", "completed")
return InstallFFmpegWithBrewResponse{
Success: true,
Message: "FFmpeg installed successfully via Homebrew",
}
}
type ConvertAudioRequest struct { type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"` InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"` OutputFormat string `json:"output_format"`
+91
View File
@@ -81,6 +81,28 @@ func GetFFmpegPath() (string, error) {
return localPath, nil return localPath, nil
} }
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
homebrewPath := "/opt/homebrew/bin/" + ffmpegName
if _, err := os.Stat(homebrewPath); err == nil {
return homebrewPath, nil
}
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
homebrewPath := "/usr/local/bin/" + ffmpegName
if _, err := os.Stat(homebrewPath); err == nil {
return homebrewPath, nil
}
}
if runtime.GOOS != "windows" {
path, err := exec.Command("which", ffmpegName).Output()
if err == nil {
trimmed := strings.TrimSpace(string(path))
if trimmed != "" {
return trimmed, nil
}
}
}
path, err := exec.LookPath(ffmpegName) path, err := exec.LookPath(ffmpegName)
if err == nil { if err == nil {
return path, nil return path, nil
@@ -105,6 +127,28 @@ func GetFFprobePath() (string, error) {
return localPath, nil return localPath, nil
} }
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
homebrewPath := "/opt/homebrew/bin/" + ffprobeName
if _, err := os.Stat(homebrewPath); err == nil {
return homebrewPath, nil
}
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
homebrewPath := "/usr/local/bin/" + ffprobeName
if _, err := os.Stat(homebrewPath); err == nil {
return homebrewPath, nil
}
}
if runtime.GOOS != "windows" {
path, err := exec.Command("which", ffprobeName).Output()
if err == nil {
trimmed := strings.TrimSpace(string(path))
if trimmed != "" {
return trimmed, nil
}
}
}
path, err := exec.LookPath(ffprobeName) path, err := exec.LookPath(ffprobeName)
if err == nil { if err == nil {
return path, nil return path, nil
@@ -146,6 +190,53 @@ func IsFFmpegInstalled() (bool, error) {
return err == nil, nil return err == nil, nil
} }
func GetBrewPath() string {
brewPaths := []string{
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
}
for _, path := range brewPaths {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func IsBrewFFmpegInstalled() (bool, error) {
brewPath := GetBrewPath()
if brewPath == "" {
return false, nil
}
cmd := exec.Command(brewPath, "list", "ffmpeg")
setHideWindow(cmd)
err := cmd.Run()
return err == nil, nil
}
func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
brewPath := GetBrewPath()
if brewPath == "" {
return fmt.Errorf("brew not found")
}
progressCallback(10, "Installing FFmpeg via Homebrew...")
cmd := exec.Command(brewPath, "install", "ffmpeg")
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to install ffmpeg: %w - %s", err, string(output))
}
progressCallback(100, "done")
return nil
}
const ( const (
ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip" ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip"
ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz" ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz"
+30 -11
View File
@@ -5,7 +5,7 @@ import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings";
import { applyTheme } from "@/lib/themes"; import { applyTheme } from "@/lib/themes";
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App"; import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetBrewPath, InstallFFmpegWithBrew } from "../wailsjs/go/main/App";
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime"; import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { TitleBar } from "@/components/TitleBar"; import { TitleBar } from "@/components/TitleBar";
@@ -65,6 +65,7 @@ function App() {
const downloadQueue = useDownloadQueueDialog(); const downloadQueue = useDownloadQueueDialog();
const downloadProgress = useDownloadProgress(); const downloadProgress = useDownloadProgress();
const [isFFmpegInstalled, setIsFFmpegInstalled] = useState<boolean | null>(null); const [isFFmpegInstalled, setIsFFmpegInstalled] = useState<boolean | null>(null);
const [brewPath, setBrewPath] = useState<string>("");
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0); const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0);
const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState(""); const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState("");
@@ -92,6 +93,8 @@ function App() {
try { try {
const installed = await CheckFFmpegInstalled(); const installed = await CheckFFmpegInstalled();
setIsFFmpegInstalled(installed); setIsFFmpegInstalled(installed);
const brew = await GetBrewPath();
setBrewPath(brew);
} }
catch (err) { catch (err) {
console.error("Failed to check FFmpeg:", err); console.error("Failed to check FFmpeg:", err);
@@ -170,7 +173,7 @@ function App() {
console.error("Failed to load history:", err); console.error("Failed to load history:", err);
} }
}; };
const handleInstallFFmpeg = async () => { const handleInstallFFmpeg = async (useBrew: boolean = false) => {
setIsInstallingFFmpeg(true); setIsInstallingFFmpeg(true);
setFfmpegInstallProgress(0); setFfmpegInstallProgress(0);
setFfmpegInstallStatus("starting"); setFfmpegInstallStatus("starting");
@@ -187,11 +190,11 @@ function App() {
EventsOn("ffmpeg:status", (status: string) => { EventsOn("ffmpeg:status", (status: string) => {
setFfmpegInstallStatus(status); setFfmpegInstallStatus(status);
}); });
const response = await DownloadFFmpeg(); const response = useBrew ? await InstallFFmpegWithBrew() : await DownloadFFmpeg();
EventsOff("ffmpeg:progress"); EventsOff("ffmpeg:progress");
EventsOff("ffmpeg:status"); EventsOff("ffmpeg:status");
if (response.success) { if (response.success) {
toast.success("FFmpeg installed successfully!"); toast.success(useBrew ? "FFmpeg installed successfully via Homebrew!" : "FFmpeg installed successfully!");
setIsFFmpegInstalled(true); setIsFFmpegInstalled(true);
} }
else { else {
@@ -522,14 +525,23 @@ function App() {
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}> <Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
<DialogContent className="max-w-[360px] [&>button]:hidden p-6 gap-5"> <DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
<DialogHeader className="space-y-2"> <DialogHeader className="space-y-2">
<DialogTitle className="text-lg font-bold tracking-tight"> <DialogTitle className="text-lg font-bold tracking-tight">
FFmpeg Required FFmpeg Required
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal"> <DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
FFmpeg is essential for SpotiFLAC to function properly. {brewPath ? (
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data. <>
FFmpeg is essential for SpotiFLAC to function properly.
Homebrew detected. Recommended: <span className="text-foreground font-semibold">brew install ffmpeg</span>
</>
) : (
<>
FFmpeg is essential for SpotiFLAC to function properly.
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
</>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -557,13 +569,20 @@ function App() {
</div>)} </div>)}
</div>)} </div>)}
<DialogFooter className="flex-row gap-3 pt-2"> <DialogFooter className={`flex-row gap-3 pt-2 ${brewPath ? 'flex-col' : ''}`}>
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}> {!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
Exit Exit
</Button>)} </Button>)}
<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={handleInstallFFmpeg} disabled={isInstallingFFmpeg}> {brewPath ? (
{isInstallingFFmpeg ? "Installing..." : "Install now"} <Button className="flex-1 h-11 text-sm font-bold shadow-lg shadow-primary/10" onClick={() => handleInstallFFmpeg(true)} disabled={isInstallingFFmpeg}>
</Button> {isInstallingFFmpeg ? "Installing..." : "Install via Homebrew"}
</Button>
) : (
<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={() => handleInstallFFmpeg(false)} disabled={isInstallingFFmpeg}>
{isInstallingFFmpeg ? "Installing..." : "Install now"}
</Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>