From b74be57df97ae9b0d35da5cfb85341836b3ff1d7 Mon Sep 17 00:00:00 2001 From: Martin Rys Date: Mon, 2 Mar 2026 20:39:34 +0100 Subject: [PATCH] fix: Add support for downloading x64 Linux ffmpeg, fixes portable build --- src/phazor/phazor.c | 12 ++-- src/tauon/t_modules/t_main.py | 113 ++++++++++++++++++++------------ src/tauon/t_modules/t_phazor.py | 9 +-- src/tauon/t_modules/t_stream.py | 2 +- 4 files changed, 84 insertions(+), 52 deletions(-) diff --git a/src/phazor/phazor.c b/src/phazor/phazor.c index e8acc7a46..7c039ce5d 100644 --- a/src/phazor/phazor.c +++ b/src/phazor/phazor.c @@ -734,7 +734,7 @@ openmpt_module* mod = 0; Music_Emu* emu; -// FFMPEG related ----------------------------------------------------- +// FFmpeg related ----------------------------------------------------- FILE *ffm; char exe_string[4096]; @@ -749,12 +749,12 @@ void start_ffmpeg(char uri[], int start_ms) { int status = 0; if (ff_start != NULL) status = ff_start(uri, start_ms, sample_rate_out); else { - log_msg(LOG_ERROR, "pa: FFMPEG callback is NULL"); + log_msg(LOG_ERROR, "pa: FFmpeg callback is NULL"); return; } if (status != 0) { - log_msg(LOG_ERROR, "pa: Error starting FFMPEG"); + log_msg(LOG_ERROR, "pa: Error starting FFmpeg"); return; } @@ -2405,7 +2405,7 @@ int load_next() { if (codec == UNKNOWN || config_always_ffmpeg == 1) { codec = FFMPEG; - log_msg(LOG_INFO, "pa: Decode using FFMPEG\n"); + log_msg(LOG_INFO, "pa: Decode using FFmpeg\n"); } // Start decoders @@ -2974,7 +2974,7 @@ void pump_decode() { int b = 0; if (ff_read != NULL) b = ff_read(ffm_buffer, 2048); else { - log_msg(LOG_WARNING, "pa: FFMPEG read callback is NULL"); + log_msg(LOG_WARNING, "pa: FFmpeg read callback is NULL"); decoder_eos(); return; } @@ -2989,7 +2989,7 @@ void pump_decode() { read_to_buffer_char16(ffm_buffer, b); pthread_mutex_unlock(&buffer_mutex); if (b == 0) { - log_msg(LOG_INFO, "pa: FFMPEG has finished"); + log_msg(LOG_INFO, "pa: FFmpeg has finished"); decoder_eos(); } } diff --git a/src/tauon/t_modules/t_main.py b/src/tauon/t_modules/t_main.py index ea885175b..98691413c 100644 --- a/src/tauon/t_modules/t_main.py +++ b/src/tauon/t_modules/t_main.py @@ -15369,48 +15369,69 @@ def start_remote(self) -> None: self.web_thread.start() self.web_running = True - def download_ffmpeg(self, x) -> None: + def download_ffmpeg(self, _x) -> None: + """Download FFmpeg portable binary to User Data dir, supports x64 Windows and Linux""" def go() -> None: - url = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-essentials_build.zip" - sha = "04861d3339c5ebe38b56c19a15cf2c0cc97f5de4fa8910e4d47e5e6404e4a2d4" + if self.windows: + url = "https://github.com/GyanD/codexffmpeg/releases/download/8.0.1/ffmpeg-8.0.1-essentials_build.zip" + sha = "e2aaeaa0fdbc397d4794828086424d4aaa2102cef1fb6874f6ffd29c0b88b673" + elif not self.macos: + url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-03-02-13-06/ffmpeg-n8.0.1-66-g27b8d1a017-linux64-gpl-8.0.tar.xz" + sha = "e6f31af6c1ef49ceec8361185d70283ea8ad6c8ceed652d5dee66ec174a11eea" self.show_message(_("Starting download...")) - try: - f = io.BytesIO() - with requests.get(url, stream=True, timeout=1800) as r: # ffmpeg is 92MB, give it half an hour in case someone is willing to suffer it on a slow connection - dl = 0 - total_bytes = int(r.headers.get("Content-Length", 0)) - total_mb = round(total_bytes / 1000 / 1000) if total_bytes else 92 - - for data in r.iter_content(chunk_size=4096): - dl += len(data) - f.write(data) - mb = round(dl / 1000 / 1000) - if mb % 5 == 0: - self.show_message(_("Downloading... {MB}/{total_mb}").format(MB=mb, total_mb=total_mb)) - except Exception as e: - logging.exception("Download failed") - self.show_message(_("Download failed"), str(e), mode="error") - return + with io.BytesIO() as f: + try: + with requests.get(url, stream=True, timeout=1800) as r: # Windows ffmpeg is 101MB, Linux 130MB, give it half an hour in case someone is willing to suffer it on a slow connection + dl = 0 + total_bytes = int(r.headers.get("Content-Length", 0)) + if self.windows: + fallback_mb = 101 + elif not self.macos: + fallback_mb = 130 + total_mb = round(total_bytes / 1000 / 1000) if total_bytes else fallback_mb + + for data in r.iter_content(chunk_size=4096): + dl += len(data) + f.write(data) + mb = round(dl / 1000 / 1000) + if mb % 5 == 0: + self.show_message(_("Downloading... {MB}/{total_mb}").format(MB=mb, total_mb=total_mb)) + except Exception as e: + logging.exception("Download failed") + self.show_message(_("Download failed"), str(e), mode="error") + return - f.seek(0) - checksum = hashlib.sha256(f.read()).hexdigest() - if checksum != sha: - self.show_message(_("Download completed but checksum failed"), mode="error") - logging.error(f"Checksum was {checksum} but expected {sha}") - return - self.show_message(_("Download completed.. extracting")) - f.seek(0) - z = zipfile.ZipFile(f, mode="r") - exe = z.open("ffmpeg-7.1.1-essentials_build/bin/ffmpeg.exe") - with (self.user_directory / "ffmpeg.exe").open("wb") as file: - file.write(exe.read()) + f.seek(0) + checksum = hashlib.sha256(f.read()).hexdigest() + if checksum != sha: + self.show_message(_("Download completed but checksum failed"), mode="error") + logging.error(f"Checksum was {checksum} but expected {sha}") + return + self.show_message(_("Download completed.. extracting")) + f.seek(0) + if self.windows: + z = zipfile.ZipFile(f, mode="r") + with z.open("ffmpeg-8.0.1-essentials_build/bin/ffmpeg.exe") as exe, (self.user_directory / "ffmpeg.exe").open("wb") as file: + file.write(exe.read()) + + with z.open("ffmpeg-8.0.1-essentials_build/bin/ffprobe.exe") as exe, (self.user_directory / "ffprobe.exe").open("wb") as file: + file.write(exe.read()) + elif not self.macos: + import tarfile + + output_dir = self.user_directory + wanted = {"ffmpeg", "ffprobe"} - exe = z.open("ffmpeg-7.1.1-essentials_build/bin/ffprobe.exe") - with (self.user_directory / "ffprobe.exe").open("wb") as file: - file.write(exe.read()) + with tarfile.open(fileobj=f, mode="r:xz") as tar: + for member in tar.getmembers(): + filename = Path(member.name).name - exe.close() - self.show_message(_("FFMPEG fetch complete"), mode="done") + if filename in wanted and member.isfile(): + member.name = filename # strip directory structure + tar.extract(member, path=output_dir) + + + self.show_message(_("FFmpeg fetch complete"), mode="done") shooter(go) @@ -19068,13 +19089,13 @@ def get_tray_icon(self, name: str) -> str: def test_ffmpeg(self) -> bool: if self.get_ffmpeg(): return True - if self.windows: - self.show_message(_("This feature requires FFMPEG. Shall I can download that for you? (92MB)"), mode="confirm") + if not self.macos: + self.show_message(_("This feature requires FFmpeg. Shall I can download that for you? (100MB~)"), mode="confirm") self.gui.message_box_confirm_callback = self.download_ffmpeg self.gui.message_box_no_callback = None self.gui.message_box_confirm_reference = (None,) else: - self.show_message(_("FFMPEG could not be found")) + self.show_message(_("FFmpeg could not be found")) return False def get_ffmpeg(self) -> Path | None: @@ -19087,6 +19108,11 @@ def get_ffmpeg(self) -> Path | None: if path.is_file(): return path + # Portable Linux + path = self.user_directory / "ffmpeg" + if path.is_file(): + return path + path = shutil.which("ffmpeg") if path: return Path(path) @@ -19102,6 +19128,11 @@ def get_ffprobe(self) -> Path | None: if path.is_file(): return path + # Portable Linux + path = self.user_directory / "ffprobe" + if path.is_file(): + return path + path = shutil.which("ffprobe") if path: return Path(path) @@ -40049,7 +40080,7 @@ def load_prefs(bag: Bag) -> None: "Cache files from local sources too. (Useful for mounted network drives)") prefs.always_ffmpeg = cf.sync_add( "bool", "always-ffmpeg", prefs.always_ffmpeg, - "Prefer decoding using FFMPEG. Fixes stuttering on Raspberry Pi OS.") + "Prefer decoding using FFmpeg. Fixes stuttering on Raspberry Pi OS.") prefs.volume_power = cf.sync_add( "int", "volume-curve", prefs.volume_power, "1=Linear volume control. Values above one give greater control bias over lower volume range. Default: 2") diff --git a/src/tauon/t_modules/t_phazor.py b/src/tauon/t_modules/t_phazor.py index b34baf074..fa3f119fd 100644 --- a/src/tauon/t_modules/t_phazor.py +++ b/src/tauon/t_modules/t_phazor.py @@ -180,7 +180,7 @@ def worker(self) -> None: class FFRun: def __init__(self, tauon: Tauon) -> None: - self.tauon = tauon + self.tauon: Tauon = tauon self.decoder = None def close(self) -> None: @@ -200,10 +200,11 @@ def close(self) -> None: def start(self, uri: bytes, start_ms: int, samplerate: int) -> int: self.close() - path = str(self.tauon.get_ffmpeg()) - if not path: + ffmpeg_path = self.tauon.get_ffmpeg() + if ffmpeg_path is None: self.tauon.test_ffmpeg() return 1 + path = str(ffmpeg_path) cmd = [path] cmd += ["-loglevel", "quiet"] if start_ms > 0: @@ -216,7 +217,7 @@ def start(self, uri: bytes, start_ms: int, samplerate: int) -> int: try: self.decoder = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, startupinfo=startupinfo) except Exception: - logging.exception("Failed to start ffmpeg") + logging.exception("Failed to start FFmpeg") return 1 return 0 diff --git a/src/tauon/t_modules/t_stream.py b/src/tauon/t_modules/t_stream.py index 52b28e405..2fb0670d8 100644 --- a/src/tauon/t_modules/t_stream.py +++ b/src/tauon/t_modules/t_stream.py @@ -245,7 +245,7 @@ def encode(self) -> None: try: ffmpeg_path = self.tauon.get_ffmpeg() if ffmpeg_path is None: - logging.error("FFMPEG could not be found for stream encoder") + logging.error("FFmpeg could not be found for stream encoder") self.encode_running = False return