diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3e09dd5..68355d6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -81,13 +81,13 @@ jobs:
- name: Prepare artifacts
run: |
mkdir -p dist
- Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe"
+ Compress-Archive -Path "build\bin\SpotiFLAC.exe" -DestinationPath "dist\SpotiFLAC-windows.zip" -Force
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: windows-portable
- path: dist/SpotiFLAC.exe
+ name: windows-bundle
+ path: dist/SpotiFLAC-windows.zip
retention-days: 7
build-macos:
@@ -147,36 +147,33 @@ jobs:
- name: Build application
run: wails build -platform darwin/universal
- - name: Create DMG
+ - name: Create macOS bundle
run: |
mkdir -p dist
- # Install create-dmg if not available
- brew install create-dmg || true
-
- # Create DMG
- create-dmg \
- --volname "SpotiFLAC" \
- --window-pos 200 120 \
- --window-size 600 400 \
- --icon-size 100 \
- --icon "SpotiFLAC.app" 175 120 \
- --hide-extension "SpotiFLAC.app" \
- --app-drop-link 425 120 \
- "dist/SpotiFLAC.dmg" \
- "build/bin/SpotiFLAC.app" || \
- # Fallback to hdiutil if create-dmg fails
- hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg
+ ditto -c -k --sequesterRsrc --keepParent "build/bin/SpotiFLAC.app" "dist/SpotiFLAC-macos-bundle.zip"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: macos-portable
- path: dist/SpotiFLAC.dmg
+ name: macos-bundle
+ path: dist/SpotiFLAC-macos-bundle.zip
retention-days: 7
build-linux:
- name: Build Linux
- runs-on: ubuntu-24.04
+ name: Build Linux (${{ matrix.arch }})
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: amd64
+ goarch: amd64
+ runner: ubuntu-24.04
+ appimage_arch: x86_64
+ - arch: arm64
+ goarch: arm64
+ runner: ubuntu-24.04-arm
+ appimage_arch: aarch64
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -222,10 +219,15 @@ jobs:
- name: Install dependencies
run: |
sudo apt-get update
- sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
+ PACKAGES="libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick"
+ if [ "${{ matrix.goarch }}" = "amd64" ]; then
+ PACKAGES="$PACKAGES upx-ucl"
+ fi
+ sudo apt-get install -y $PACKAGES
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
- sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
+ MULTIARCH="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
+ sudo ln -sf "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.1.pc" "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.0.pc"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
@@ -237,9 +239,10 @@ jobs:
pnpm run generate-icon
- name: Build application
- run: wails build -platform linux/amd64
+ run: wails build -platform linux/${{ matrix.goarch }}
- name: Compress with UPX
+ if: matrix.goarch == 'amd64'
run: |
upx --best --lzma build/bin/SpotiFLAC
@@ -248,13 +251,13 @@ jobs:
uses: actions/cache@v4
with:
path: appimagetool
- key: appimagetool-x86_64-v1
+ key: appimagetool-${{ matrix.appimage_arch }}-v1
- name: Download appimagetool
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
run: |
- wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
- wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
+ wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${{ matrix.appimage_arch }}.AppImage || \
+ wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${{ matrix.appimage_arch }}.AppImage
- name: Make appimagetool executable
run: chmod +x appimagetool
@@ -309,13 +312,13 @@ jobs:
# Create AppImage
mkdir -p dist
- ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC.AppImage
+ ARCH=${{ matrix.appimage_arch }} ./appimagetool --no-appstream AppDir dist/SpotiFLAC-linux-${{ matrix.arch }}.AppImage
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
- name: linux-portable
- path: dist/SpotiFLAC.AppImage
+ name: linux-appimage-${{ matrix.arch }}
+ path: dist/SpotiFLAC-linux-${{ matrix.arch }}.AppImage
retention-days: 7
create-release:
@@ -343,6 +346,13 @@ jobs:
- name: Display structure of downloaded files
run: ls -R artifacts
+ - name: Create Linux bundle
+ run: |
+ mkdir -p release/SpotiFLAC-linux-bundle
+ cp artifacts/linux-appimage-amd64/*.AppImage release/SpotiFLAC-linux-bundle/
+ cp artifacts/linux-appimage-arm64/*.AppImage release/SpotiFLAC-linux-bundle/
+ tar -czf release/SpotiFLAC-linux-bundle.tar.gz -C release SpotiFLAC-linux-bundle
+
- name: Create Release
uses: softprops/action-gh-release@v2
with:
@@ -354,15 +364,20 @@ jobs:
## Downloads
- - `SpotiFLAC.exe` - Windows
- - `SpotiFLAC.dmg` - macOS
- - `SpotiFLAC.AppImage` - Linux
+ - `SpotiFLAC-windows.zip` - amd64
+ - `SpotiFLAC-macos-bundle.zip` - amd64 + arm64
+ - `SpotiFLAC-linux-bundle.tar.gz` - amd64 + arm64v8
Linux Requirements
The AppImage requires `webkit2gtk-4.1` to be installed on your system:
+ Choose the correct AppImage after extracting the bundle:
+
+ - `SpotiFLAC-linux-amd64.AppImage` - amd64
+ - `SpotiFLAC-linux-arm64.AppImage` - arm64v8
+
**Ubuntu/Debian:**
```bash
sudo apt install libwebkit2gtk-4.1-0
@@ -380,14 +395,14 @@ jobs:
After installing the dependency, make the AppImage executable:
```bash
- chmod +x SpotiFLAC.AppImage
- ./SpotiFLAC.AppImage
+ tar -xzf SpotiFLAC-linux-bundle.tar.gz
+ chmod +x SpotiFLAC-linux-*.AppImage
```
files: |
- artifacts/windows-portable/*.exe
- artifacts/macos-portable/*.dmg
- artifacts/linux-portable/*.AppImage
+ artifacts/windows-bundle/*.zip
+ artifacts/macos-bundle/*.zip
+ release/SpotiFLAC-linux-bundle.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go
index b9cd3e3..f228bb4 100644
--- a/backend/ffmpeg.go
+++ b/backend/ffmpeg.go
@@ -83,6 +83,37 @@ func GetFFmpegDir() (string, error) {
return EnsureAppDir()
}
+func resolveSystemExecutable(executableName string) string {
+ if runtime.GOOS == "darwin" {
+ candidates := []string{
+ "/opt/homebrew/bin/" + executableName,
+ "/usr/local/bin/" + executableName,
+ }
+ for _, candidate := range candidates {
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+ }
+ }
+
+ if runtime.GOOS != "windows" {
+ path, err := exec.Command("which", executableName).Output()
+ if err == nil {
+ trimmed := strings.TrimSpace(string(path))
+ if trimmed != "" {
+ return trimmed
+ }
+ }
+ }
+
+ path, err := exec.LookPath(executableName)
+ if err == nil {
+ return path
+ }
+
+ return ""
+}
+
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
@@ -94,38 +125,15 @@ func GetFFmpegPath() (string, error) {
ffmpegName = "ffmpeg.exe"
}
+ if path := resolveSystemExecutable(ffmpegName); path != "" {
+ return path, nil
+ }
+
localPath := filepath.Join(ffmpegDir, ffmpegName)
if _, err := os.Stat(localPath); err == 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)
- if err == nil {
- return path, nil
- }
-
return localPath, nil
}
@@ -140,38 +148,15 @@ func GetFFprobePath() (string, error) {
ffprobeName = "ffprobe.exe"
}
+ if path := resolveSystemExecutable(ffprobeName); path != "" {
+ return path, nil
+ }
+
localPath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(localPath); err == 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)
- if err == nil {
- return path, nil
- }
-
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
}
@@ -205,7 +190,11 @@ func IsFFmpegInstalled() (bool, error) {
setHideWindow(cmd)
err = cmd.Run()
- return err == nil, nil
+ if err != nil {
+ return false, nil
+ }
+
+ return IsFFprobeInstalled()
}
func GetBrewPath() string {
@@ -255,10 +244,38 @@ func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
return nil
}
-const (
- 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"
-)
+const ffmpegReleaseBaseURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.1"
+
+func buildFFmpegReleaseURL(assetName string) string {
+ return ffmpegReleaseBaseURL + "/" + assetName
+}
+
+func getFFmpegDownloadURLs() ([]string, []string, error) {
+ switch runtime.GOOS {
+ case "windows":
+ return []string{buildFFmpegReleaseURL("ffmpeg-windows.zip")}, []string{buildFFmpegReleaseURL("ffprobe-windows.zip")}, nil
+ case "linux":
+ switch runtime.GOARCH {
+ case "amd64":
+ return []string{buildFFmpegReleaseURL("ffmpeg-linux-amd64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-linux-amd64.zip")}, nil
+ case "arm64":
+ return []string{buildFFmpegReleaseURL("ffmpeg-linux-arm64v8.zip")}, []string{buildFFmpegReleaseURL("ffprobe-linux-arm64v8.zip")}, nil
+ default:
+ return nil, nil, fmt.Errorf("unsupported Linux architecture: %s", runtime.GOARCH)
+ }
+ case "darwin":
+ switch runtime.GOARCH {
+ case "amd64":
+ return []string{buildFFmpegReleaseURL("ffmpeg-macos-amd64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-macos-amd64.zip")}, nil
+ case "arm64":
+ return []string{buildFFmpegReleaseURL("ffmpeg-macos-arm64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-macos-arm64.zip")}, nil
+ default:
+ return nil, nil, fmt.Errorf("unsupported macOS architecture: %s", runtime.GOARCH)
+ }
+ default:
+ return nil, nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
+ }
+}
func DownloadFFmpeg(progressCallback func(int)) error {
@@ -276,57 +293,30 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
- if runtime.GOOS == "darwin" {
- ffmpegInstalled, _ := IsFFmpegInstalled()
- ffprobeInstalled, _ := IsFFprobeInstalled()
+ ffmpegInstalled, _ := IsFFmpegInstalled()
+ ffprobeInstalled, _ := IsFFprobeInstalled()
- isARM := runtime.GOARCH == "arm64"
+ ffmpegURLs, ffprobeURLs, err := getFFmpegDownloadURLs()
+ if err != nil {
+ return err
+ }
- var macFFmpegURLs []string
- var macFFprobeURLs []string
-
- if isARM {
-
- macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"}
- macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"}
- } else {
-
- macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"}
- macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"}
+ if !ffmpegInstalled && !ffprobeInstalled {
+ if err := downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
+ return err
}
-
- if !ffmpegInstalled && !ffprobeInstalled {
- if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
- return err
- }
- if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
- return err
- }
- } else if !ffmpegInstalled {
- if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
- return err
- }
- } else if !ffprobeInstalled {
- if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
- return err
- }
+ if err := downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
+ return err
}
return nil
}
- var url string
- switch runtime.GOOS {
- case "windows":
- url = ffmpegWindowsURL
- case "linux":
- url = ffmpegLinuxURL
- default:
- return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
+ if !ffmpegInstalled {
+ return downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 100)
}
- fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
- if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
- return err
+ if !ffprobeInstalled {
+ return downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 0, 100)
}
return nil
@@ -452,10 +442,13 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
}
fmt.Printf("[FFmpeg] Extracting...\n")
- if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
+ if strings.HasSuffix(url, ".tar.xz") {
return extractTarXz(tmpFile.Name(), destDir)
}
- return extractZip(tmpFile.Name(), destDir)
+ if strings.HasSuffix(url, ".zip") {
+ return extractZip(tmpFile.Name(), destDir)
+ }
+ return fmt.Errorf("unsupported archive format for %s", url)
}
func extractZip(zipPath, destDir string) error {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2b62e7f..bb1ae62 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,7 +5,7 @@ import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
-import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetBrewPath, GetRecentFetches, InstallFFmpegWithBrew, SaveRecentFetches } from "../wailsjs/go/main/App";
+import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App";
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { TitleBar } from "@/components/TitleBar";
@@ -153,7 +153,6 @@ function App() {
const downloadQueue = useDownloadQueueDialog();
const downloadProgress = useDownloadProgress();
const [isFFmpegInstalled, setIsFFmpegInstalled] = useState(null);
- const [brewPath, setBrewPath] = useState("");
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0);
const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState("");
@@ -181,8 +180,6 @@ function App() {
try {
const installed = await CheckFFmpegInstalled();
setIsFFmpegInstalled(installed);
- const brew = await GetBrewPath();
- setBrewPath(brew);
}
catch (err) {
console.error("Failed to check FFmpeg:", err);
@@ -263,7 +260,7 @@ function App() {
localStorage.removeItem(HISTORY_KEY);
}
}, [persistRecentHistory]);
- const handleInstallFFmpeg = async (useBrew: boolean = false) => {
+ const handleInstallFFmpeg = async () => {
setIsInstallingFFmpeg(true);
setFfmpegInstallProgress(0);
setFfmpegInstallStatus("starting");
@@ -280,11 +277,11 @@ function App() {
EventsOn("ffmpeg:status", (status: string) => {
setFfmpegInstallStatus(status);
});
- const response = useBrew ? await InstallFFmpegWithBrew() : await DownloadFFmpeg();
+ const response = await DownloadFFmpeg();
EventsOff("ffmpeg:progress");
EventsOff("ffmpeg:status");
if (response.success) {
- toast.success(useBrew ? "FFmpeg installed successfully via Homebrew!" : "FFmpeg installed successfully!");
+ toast.success("FFmpeg installed successfully!");
setIsFFmpegInstalled(true);
}
else {
@@ -664,13 +661,9 @@ function App() {
FFmpeg Required
- {brewPath ? (<>
- FFmpeg is essential for SpotiFLAC to function properly.
- Homebrew detected. Recommended: brew install ffmpeg
- >) : (<>
- FFmpeg is essential for SpotiFLAC to function properly.
- This setup will download about 100-200MB of data.
- >)}
+ SpotiFLAC checks your system for FFmpeg and FFprobe first.
+ If they are not available, the required binaries will be downloaded from GitHub.
+ This setup downloads about 30-40MB of data.
@@ -698,15 +691,13 @@ function App() {
)}
)}
-
+
{!isInstallingFFmpeg && ()}
- {brewPath ? () : (