├── tests ├── requirements.txt ├── test_package.py ├── _test_qt.py ├── _test_win.py ├── _test_macos.py └── _test_linux.py ├── requirements.txt ├── src └── ffmpeg_downloader │ ├── ffdl.py │ ├── qt │ ├── __init__.py │ ├── __main__.py │ ├── qt_compat.py │ ├── assets │ │ └── FFmpeg_icon.svg │ └── wizard.py │ ├── _path.py │ ├── _progress.py │ ├── _download_helper.py │ ├── __init__.py │ ├── _config.py │ ├── _linux.py │ ├── _macos.py │ ├── _win32.py │ ├── _backend.py │ └── __main__.py ├── .github ├── dependabot.yml └── workflows │ └── test_n_pub.yml ├── sandbox ├── test_appdirs.py └── test_py7z.py ├── .vscode └── launch.json ├── pyproject.toml ├── CHANGELOG.md ├── .gitignore ├── README.rst └── LICENSE /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -r tests/requirements.txt 3 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/ffdl.py: -------------------------------------------------------------------------------- 1 | from .__main__ import main 2 | 3 | 4 | def run(): 5 | main("ffdl") 6 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/qt/__init__.py: -------------------------------------------------------------------------------- 1 | from .wizard import InstallFFmpegWizard 2 | 3 | __all__ = ["InstallFFmpegWizard"] 4 | -------------------------------------------------------------------------------- /tests/test_package.py: -------------------------------------------------------------------------------- 1 | import ffmpeg_downloader as ffdl 2 | 3 | 4 | def test(): 5 | assert ffdl.installed('ffmpeg') 6 | assert ffdl.installed('ffprobe') 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | ignore: 8 | - dependency-name: 'actions/*' 9 | -------------------------------------------------------------------------------- /sandbox/test_appdirs.py: -------------------------------------------------------------------------------- 1 | import platformdirs 2 | 3 | # get current version 4 | # get current paths (ffmpeg/ffprobe) 5 | # delete current version 6 | # copy 7 | 8 | app = "ffmpeg-downloader", "python-ffmpegio" 9 | 10 | print(platformdirs.user_data_dir(*app)) 11 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/_path.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from platformdirs import user_data_dir 3 | 4 | def get_dir(): 5 | return user_data_dir("ffmpeg-downloader", "ffmpegio") 6 | 7 | def get_cache_dir(): 8 | return path.join(get_dir(),'cache') 9 | 10 | def get_bin_dir(): 11 | return path.join(get_dir(),'cache') 12 | -------------------------------------------------------------------------------- /tests/_test_qt.py: -------------------------------------------------------------------------------- 1 | # if __name__ == "__main__": 2 | import logging 3 | 4 | logging.basicConfig(level=logging.DEBUG) 5 | 6 | import sys 7 | from PyQt6.QtWidgets import QApplication 8 | from ffmpeg_downloader.qt.wizard import InstallFFmpegWizard 9 | 10 | app = QApplication(sys.argv) 11 | wizard = InstallFFmpegWizard() 12 | wizard.show() 13 | sys.exit(app.exec()) 14 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/qt/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt6.QtWidgets import QApplication 3 | from ffmpeg_downloader.qt.wizard import InstallFFmpegWizard 4 | 5 | 6 | def main(): 7 | app = QApplication(sys.argv) 8 | wizard = InstallFFmpegWizard() 9 | wizard.show() 10 | sys.exit(app.exec()) 11 | 12 | 13 | if __name__ == "__main__": 14 | main() 15 | -------------------------------------------------------------------------------- /tests/_test_win.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | import ffmpeg_downloader._win32 as _ 3 | 4 | _.retrieve_releases_page(1) 5 | 6 | 7 | ver = _.get_latest_version() 8 | release_assets = _.find_release_assets(ver) 9 | 10 | # find asset of the latest snapshot 11 | snapshot = _.get_latest_snapshot() 12 | snapshot_assets = _.find_snapshot_assets(snapshot) 13 | 14 | print(f"release v{ver}") 15 | pprint(release_assets) 16 | print(f"snapshot {snapshot}") 17 | pprint(snapshot_assets) 18 | 19 | -------------------------------------------------------------------------------- /sandbox/test_py7z.py: -------------------------------------------------------------------------------- 1 | from py7zlib import Archive7z 2 | 3 | #setup 4 | with open(filename, 'rb') as fp: 5 | archive = Archive7z(fp) 6 | filenames = archive.getnames() 7 | for filename in filenames: 8 | cf = archive.getmember(filename) 9 | try: 10 | cf.checkcrc() 11 | except: 12 | raise RuntimeError(f"crc failed for {filename}") 13 | 14 | b = cf.read() 15 | try: 16 | assert len(b)==cf.uncompressed 17 | except: 18 | raise RuntimeError(f"incorrect uncompressed file size for {filename}") 19 | 20 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/_progress.py: -------------------------------------------------------------------------------- 1 | from tqdm import tqdm 2 | from tqdm.utils import CallbackIOWrapper 3 | 4 | 5 | class DownloadProgress: 6 | def __init__(self, nbytes) -> None: 7 | self.tqdm = tqdm(desc="downloading", total=nbytes, unit=" bytes", leave=False) 8 | self.last = 0 9 | 10 | def update(self, nread): 11 | self.tqdm.update(nread - self.last) 12 | self.last = nread 13 | 14 | def close(self): 15 | self.tqdm.close() 16 | 17 | 18 | class InstallProgress: 19 | def __init__(self, sz) -> None: 20 | self.tqdm = tqdm( 21 | desc="installing", 22 | unit="B", 23 | unit_scale=True, 24 | unit_divisor=1024, 25 | total=sz, 26 | leave=False, 27 | ) 28 | 29 | def io_wrapper(self, fi): 30 | return CallbackIOWrapper(self.tqdm.update, fi) 31 | 32 | def close(self): 33 | self.tqdm.close() 34 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: Module", 6 | "type": "python", 7 | "request": "launch", 8 | "module": "${fileDirnameBasename}", 9 | "justMyCode": true, 10 | "args": [ 11 | "install", 12 | "-y" 13 | ], 14 | }, 15 | { 16 | "name": "Python: Current File", 17 | "type": "python", 18 | "request": "launch", 19 | "program": "${file}", 20 | "console": "integratedTerminal", 21 | "justMyCode": false 22 | }, 23 | { 24 | "name": "Python: Module Uninstall", 25 | "type": "python", 26 | "request": "launch", 27 | "module": "ffmpeg_downloader", 28 | "args": [ 29 | "uninstall", 30 | "-y" 31 | ], 32 | "console": "integratedTerminal", 33 | "justMyCode": true, 34 | "cwd": "${workspaceFolder}", 35 | "env": { 36 | "PYTHONPATH": "${workspaceRoot}" 37 | } 38 | } 39 | ] 40 | } -------------------------------------------------------------------------------- /tests/_test_macos.py: -------------------------------------------------------------------------------- 1 | from ffmpeg_downloader._macos import ( 2 | get_latest_version, 3 | get_latest_snapshot, 4 | update_releases_info, 5 | get_download_info, 6 | extract, 7 | set_env_path,clr_env_path 8 | ) 9 | from ffmpeg_downloader._config import Config 10 | from ffmpeg_downloader._download_helper import download_file 11 | from pprint import pprint 12 | from packaging.version import Version 13 | import re 14 | from os import makedirs, path 15 | 16 | print(set_env_path('~/test/ffmpeg')) 17 | clr_env_path('~/test/ffmpeg') 18 | exit() 19 | dldir = path.join("tests", "macos") 20 | makedirs(dldir, exist_ok=True) 21 | 22 | ver = get_latest_snapshot() 23 | config = Config().snapshot 24 | pprint(ver) 25 | pprint(config) 26 | info = get_download_info(ver, None) 27 | pprint(info) 28 | for name, url, content_type, _ in info: 29 | try: 30 | download_file(path.join(dldir, name), url, headers={"Accept": content_type}) 31 | except: 32 | pass 33 | 34 | print(extract((path.join(dldir, name) for name, *_ in info), "tests/install")) 35 | 36 | 37 | ver = get_latest_version() 38 | update_releases_info() 39 | info = get_download_info(ver, None) 40 | 41 | for name, url, content_type, _ in info: 42 | try: 43 | download_file(path.join(dldir, name), url, headers={"Accept": content_type}) 44 | except: 45 | pass 46 | -------------------------------------------------------------------------------- /tests/_test_linux.py: -------------------------------------------------------------------------------- 1 | from ffmpeg_downloader._linux import ( 2 | get_latest_version, 3 | get_latest_snapshot, 4 | update_releases_info, 5 | get_download_info, 6 | extract, 7 | ) 8 | from ffmpeg_downloader._config import Config 9 | from ffmpeg_downloader._download_helper import download_file 10 | from pprint import pprint 11 | from packaging.version import Version 12 | import re 13 | from os import makedirs, path 14 | 15 | dldir = path.join("tests", "linux") 16 | makedirs(dldir, exist_ok=True) 17 | 18 | update_releases_info(True) 19 | 20 | # ver = get_latest_snapshot() 21 | # config = Config().snapshot 22 | # pprint(ver) 23 | # pprint(config) 24 | # info = get_download_info(ver, None) 25 | # pprint(info) 26 | # for name, url, content_type, _ in info: 27 | # dst = path.join(dldir, name) 28 | # if path.exists(dst): 29 | # continue 30 | # try: 31 | # download_file(dst, url, headers={"Accept": content_type}) 32 | # except Exception as e: 33 | # print('errored', type(e),name,url) 34 | # pass 35 | 36 | # print(extract([path.join(dldir, name) for name, *_ in info], "tests/install")) 37 | 38 | pprint(Config().releases) 39 | 40 | ver = get_latest_version() 41 | print(ver) 42 | info = get_download_info(ver, None) 43 | print(info) 44 | 45 | # for name, url, content_type, _ in info: 46 | # print(name,url) 47 | # try: 48 | # download_file(path.join(dldir, name), url, headers={"Accept": content_type}) 49 | # except: 50 | # pass 51 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/_download_helper.py: -------------------------------------------------------------------------------- 1 | import os, stat 2 | import requests 3 | from requests.adapters import HTTPAdapter 4 | 5 | 6 | def download_info( 7 | url, headers={}, params={}, stream=False, timeout=None, retries=None, proxy=None 8 | ): 9 | http = requests.Session() 10 | http.mount("https://", HTTPAdapter(max_retries=5)) 11 | response = http.get( 12 | url, 13 | headers=headers, 14 | params=params, 15 | timeout=timeout, 16 | stream=stream, 17 | proxies=proxy, 18 | ) 19 | return response 20 | 21 | 22 | def download_file( 23 | outfile, 24 | url, 25 | headers={}, 26 | params={}, 27 | progress=None, 28 | timeout=None, 29 | retries=None, 30 | proxy=None, 31 | ): 32 | 33 | response = download_info( 34 | url, headers, params, stream=True, timeout=timeout, retries=retries, proxy=proxy 35 | ) 36 | 37 | nbytes = int(response.headers.get("Content-Length", 0)) 38 | 39 | if progress: 40 | progress = progress(nbytes) 41 | 42 | blksz = nbytes // 32 or 1024 * 1024 43 | with open(outfile, "wb") as f: 44 | nread = 0 45 | for b in response.iter_content(chunk_size=blksz): 46 | if not b: 47 | break 48 | f.write(b) 49 | nread += len(b) 50 | if progress: 51 | progress.update(nread) 52 | 53 | return nbytes 54 | 55 | 56 | def chmod(binfile): 57 | # Set bits for execution and read for all users. 58 | exe_bits = stat.S_IXOTH | stat.S_IXUSR | stat.S_IXGRP 59 | read_bits = stat.S_IRUSR | stat.S_IRGRP | stat.S_IXGRP 60 | write_bits = stat.S_IWUSR | stat.S_IWGRP | stat.S_IWGRP 61 | os.chmod(binfile, exe_bits | read_bits | write_bits) 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.pytest.ini_options] 6 | testpaths = ["tests"] 7 | 8 | [project] 9 | name = "ffmpeg_downloader" 10 | description = "FFmpeg Release Build Downloader" 11 | readme = "README.rst" 12 | keywords = ["multimedia", "ffmpeg", "ffprobe", "download"] 13 | license = { text = "GPL-2.0 License" } 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 17 | "Topic :: Multimedia :: Sound/Audio", 18 | "Topic :: Multimedia :: Sound/Audio :: Capture/Recording", 19 | "Topic :: Multimedia :: Sound/Audio :: Conversion", 20 | "Topic :: Multimedia :: Video", 21 | "Topic :: Multimedia :: Video :: Capture", 22 | "Topic :: Multimedia :: Video :: Conversion", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | ] 29 | dynamic = ["version"] 30 | requires-python = ">=3.7" 31 | dependencies = ["requests", "tqdm>=4.40.0", "platformdirs", "packaging"] 32 | 33 | [project.urls] 34 | Repository = "https://github.com/python-ffmpegio/python-ffmpeg-downloader" 35 | Issues = "https://github.com/python-ffmpegio/python-ffmpegio-downloader/issues" 36 | Pull_Requests = "https://github.com/python-ffmpegio/python-ffmpegio-downloader/pulls" 37 | 38 | [project.scripts] 39 | ffdl = "ffmpeg_downloader.ffdl:run" 40 | 41 | [project.gui-scripts] 42 | ffdl-gui = "ffmpeg_downloader.qt.__main__:main" 43 | 44 | [tool.setuptools.dynamic] 45 | version = { attr = "ffmpeg_downloader.__version__" } 46 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __version__ = "0.4.1" 4 | 5 | import os 6 | from . import _backend as _ 7 | 8 | 9 | def add_path(): 10 | """Add FFmpeg directory to the process environment path 11 | 12 | .. note:: 13 | 14 | The system path is not updated with this command. The FFmpeg path is 15 | only added during the life of the calling Python process. 16 | 17 | To add to the (per-user) system path, re-install the FFmpeg with `--add-path` 18 | option in cli: 19 | 20 | ffdl install -U --add-path 21 | 22 | .. note:: 23 | 24 | This function does not check if the FFmpeg is installed in the path. Use 25 | `ffmpeg_downloaer.installed()` to check. 26 | 27 | """ 28 | os.environ["PATH"] = os.pathsep.join([os.environ["PATH"], _.ffmpeg_path()]) 29 | 30 | 31 | def installed( 32 | bin_name: str = "ffmpeg", *, return_path: bool = False 33 | ) -> bool | str | None: 34 | """True if FFmpeg binary is installed 35 | 36 | :param bin_name: FFmpeg command name, defaults to 'ffmpeg' 37 | :param return_path: True to return the valid path (or None if not installed), 38 | defaults to False 39 | :return: True if installed 40 | """ 41 | 42 | p = _.ffmpeg_path(bin_name) 43 | tf = os.path.isfile(p) 44 | return tf if not return_path else p if tf else None 45 | 46 | 47 | def __getattr__(name): # per PEP 562 48 | try: 49 | return { 50 | "ffmpeg_dir": lambda: _.ffmpeg_path(), 51 | "ffmpeg_path": lambda: installed("ffmpeg", return_path=True), 52 | "ffprobe_path": lambda: installed("ffprobe", return_path=True), 53 | "ffplay_path": lambda: installed("ffplay", return_path=True), 54 | "ffmpeg_version": _.ffmpeg_version, 55 | }[name]() 56 | except: 57 | raise AttributeError(f"module '{__name__}' has no attribute '{name}'") 58 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/_config.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | import os 3 | from ._path import get_dir 4 | from pickle import load, dump 5 | import json 6 | from datetime import datetime, timedelta 7 | 8 | 9 | def _config_file(): 10 | return path.join(get_dir(), "config.data") 11 | 12 | 13 | class Config: 14 | 15 | __instance = None 16 | __inited = False 17 | 18 | def __new__(cls) -> "Config": 19 | if cls.__instance is None: 20 | cls.__instance = super().__new__(cls) 21 | return cls.__instance 22 | 23 | def __init__(self) -> None: 24 | if type(self).__inited: 25 | return 26 | type(self).__inited = True 27 | 28 | self._data = None 29 | self.snapshot = {} 30 | 31 | os.makedirs(get_dir(), exist_ok=True) 32 | self.revert() 33 | 34 | def revert(self): 35 | try: 36 | with open(_config_file(), "rb") as f: 37 | self._data = load(f) 38 | except: 39 | self._data = {} 40 | 41 | def dump(self): 42 | with open(_config_file(), "wb") as f: 43 | dump(self._data, f) 44 | 45 | @property 46 | def releases(self): 47 | try: 48 | return {**self._data["releases"]} 49 | except: 50 | return {} 51 | 52 | @releases.setter 53 | def releases(self, value): 54 | self._data["releases"] = {**value} 55 | self._data["last_updated"] = datetime.now() 56 | 57 | def is_stale(self, stale_in=1): 58 | try: 59 | return datetime.now() - timedelta(stale_in) > self._data["last_updated"] 60 | except: 61 | return True 62 | 63 | @property 64 | def install_setup(self): 65 | try: 66 | return {**self._data["install_setup"]} 67 | except: 68 | return {} 69 | 70 | @install_setup.setter 71 | def install_setup(self, value): 72 | self._data["install_setup"] = value and {**value} 73 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | 8 | ## [0.4.1] - 2025-11-14 9 | 10 | ### Fixed 11 | 12 | - (PR#9 by @ucordia) Fix dst argument to accept a single value instead of list 13 | 14 | ## [0.4.0] - 2025-02-10 15 | 16 | ### Changed 17 | 18 | - `ffdl.installed` can return the path with new optional argument `return_path=True` 19 | - `ffdl.ffxxx_path` attributes returns None if the binary is not installed 20 | 21 | ### Fixed 22 | 23 | - `main` - allows no input argument (display the help text) 24 | 25 | ## [0.3.0] - 2023-12-07 26 | 27 | ### Added 28 | 29 | - Support for aarch64 30 | - Qt QWizard subclass InstallFFmpegWizard 31 | - Run wizard with command `ffdl-gui` 32 | - Switched to using platformdirs package from appdirs 33 | 34 | ### Fixed 35 | 36 | - uninstall command argument processing 37 | - uninstall clear env vars 38 | - linux clr_symlinks() 39 | - downloader no longer try to copy again to cache dir 40 | - [win32] error finding snapshot assets 41 | 42 | ## [0.2.0] - 2022-11-19 43 | 44 | ### Changed 45 | 46 | - Completely reworked CLI commands to mimic pip: install, uninstall, download, list, search 47 | 48 | ### Added 49 | 50 | - Support to install the latest git master snapshot and old releases 51 | - `--add-path` CLI option to insert FFmpeg path to system user path 52 | - `add_path()` Python function to add FFmpeg path to process path 53 | - Other CLI options 54 | 55 | 56 | ## [0.1.4] - 2022-02-27 57 | 58 | ### Changed 59 | 60 | - Switched from `urllib` to `requests` package for HTTP interface 61 | ## [0.1.3] - 2022-02-22 62 | 63 | ### Fixed 64 | 65 | - Fixed `ffmpeg_dir` attribute 66 | 67 | ## [0.1.2] - 2022-02-20 68 | 69 | ### Fixed 70 | 71 | - PyPI description not shown 72 | 73 | ## [0.1.1] - 2022-02-20 74 | 75 | - First release via GitHub Action 76 | 77 | [Unreleased]: https://github.com/python-ffmpegio/python-ffmpegio/compare/v0.4.0...HEAD 78 | [0.4.0]: https://github.com/python-ffmpegio/python-ffmpegio/compare/v0.3.0...v0.4.0 79 | [0.3.0]: https://github.com/python-ffmpegio/python-ffmpegio/compare/v0.2.0...v0.3.0 80 | [0.2.0]: https://github.com/python-ffmpegio/python-ffmpegio/compare/v0.1.4...v0.2.0 81 | [0.1.4]: https://github.com/python-ffmpegio/python-ffmpegio/compare/v0.1.3...v0.1.4 82 | [0.1.3]: https://github.com/python-ffmpegio/python-ffmpegio/compare/v0.1.2...v0.1.3 83 | [0.1.2]: https://github.com/python-ffmpegio/python-ffmpegio/compare/v0.1.1...v0.1.2 84 | [0.1.1]: https://github.com/python-ffmpegio/python-ffmpegio/compare/94bbcc4...v0.1.1 85 | -------------------------------------------------------------------------------- /.github/workflows/test_n_pub.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | tests: 12 | name: Python ${{ matrix.python-version }} • ${{ matrix.os }} 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | python-version: ["3.13"] 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | include: 19 | - os: ubuntu-latest 20 | python-version: 3.9 21 | - os: ubuntu-latest 22 | python-version: "3.10" 23 | - os: ubuntu-latest 24 | python-version: "3.11" 25 | - os: ubuntu-latest 26 | python-version: "3.12" 27 | 28 | steps: 29 | - run: echo ${{github.ref}} 30 | 31 | - uses: actions/checkout@v2 32 | 33 | - name: Setup Python ${{ matrix.python-version }} 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | architecture: ${{ matrix.arch }} 38 | 39 | - name: Setup Python dependencies 40 | run: | 41 | python -m pip install -U pip setuptools pytest pytest-github-actions-annotate-failures 42 | 43 | - name: Install ffmpeg_downloader package 44 | run: pip install -q . 45 | 46 | - name: Install ffmpeg 47 | run: ffdl install -y 48 | 49 | - name: Install ffmpeg snapshot 50 | run: ffdl install -y -U snapshot 51 | 52 | - name: Run tests 53 | run: pytest -vv 54 | 55 | - name: Uninstall ffmpeg 56 | run: ffdl uninstall -y 57 | 58 | distribute: 59 | name: Distribution 60 | permissions: write-all 61 | runs-on: ubuntu-latest 62 | needs: tests 63 | if: startsWith(github.ref, 'refs/tags') 64 | steps: 65 | - uses: actions/checkout@v2 66 | 67 | - name: Setup Python 68 | uses: actions/setup-python@v2 69 | with: 70 | python-version: "3.x" # Version range or exact version of a Python version to use, using SemVer's version range syntax 71 | 72 | - name: Setup Python dependencies 73 | run: | 74 | python -m pip install -U pip setuptools 75 | pip install -U build 76 | 77 | - name: Build a binary wheel and a source tarball 78 | run: python -m build --sdist --wheel --outdir dist/ . 79 | 80 | - name: add python distribution files to release 81 | uses: softprops/action-gh-release@v2 82 | with: 83 | files: dist/* 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | 87 | # - name: Publish distribution 📦 to Test PyPI 88 | # uses: pypa/gh-action-pypi-publish@release/v1 89 | # with: 90 | # password: ${{ secrets.TEST_PYPI_API_TOKEN }} 91 | # repository_url: https://test.pypi.org/legacy/ 92 | # skip_existing: true 93 | 94 | - name: Publish distribution 📦 to PyPI 95 | uses: pypa/gh-action-pypi-publish@release/v1 96 | # with: 97 | # password: ${{ secrets.PYPI_API_TOKEN }} 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | .vscode/* 141 | # !.vscode/settings.json 142 | !.vscode/tasks.json 143 | !.vscode/launch.json 144 | !.vscode/extensions.json 145 | *.code-workspace 146 | 147 | # Local History for Visual Studio Code 148 | .history/ 149 | 150 | # GitHub page 151 | docs/ 152 | !docs/.nojekyll 153 | 154 | tests/*.7z 155 | tests/*.tar.xz 156 | tests/*/ 157 | tests/*.zip 158 | .DS_Store 159 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/qt/qt_compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Qt binding and backend selector. 3 | 4 | The selection logic is as follows: 5 | - if any of PyQt6, PySide6, PyQt5, or PySide2 have already been 6 | imported (checked in that order), use it; 7 | - otherwise, if the QT_API environment variable (used by Enthought) is set, use 8 | it to determine which binding to use; 9 | 10 | [taken from matplotlib] 11 | """ 12 | 13 | import operator 14 | import os 15 | import platform 16 | import sys 17 | 18 | from packaging.version import parse as parse_version 19 | 20 | QT_API_PYQT6 = "PyQt6" 21 | QT_API_PYSIDE6 = "PySide6" 22 | QT_API_PYQT5 = "PyQt5" 23 | QT_API_PYSIDE2 = "PySide2" 24 | QT_API_ENV = os.environ.get("QT_API") 25 | if QT_API_ENV is not None: 26 | QT_API_ENV = QT_API_ENV.lower() 27 | _ETS = { # Mapping of QT_API_ENV to requested binding. 28 | "pyqt6": QT_API_PYQT6, 29 | "pyside6": QT_API_PYSIDE6, 30 | "pyqt5": QT_API_PYQT5, 31 | "pyside2": QT_API_PYSIDE2, 32 | } 33 | # First, check if anything is already imported. 34 | if sys.modules.get("PyQt6.QtCore"): 35 | QT_API = QT_API_PYQT6 36 | elif sys.modules.get("PySide6.QtCore"): 37 | QT_API = QT_API_PYSIDE6 38 | elif sys.modules.get("PyQt5.QtCore"): 39 | QT_API = QT_API_PYQT5 40 | elif sys.modules.get("PySide2.QtCore"): 41 | QT_API = QT_API_PYSIDE2 42 | # A non-Qt backend was selected but we still got there (possible, e.g., when 43 | # fully manually embedding Matplotlib in a Qt app without using pyplot). 44 | elif QT_API_ENV is None: 45 | QT_API = None 46 | elif QT_API_ENV in _ETS: 47 | QT_API = _ETS[QT_API_ENV] 48 | else: 49 | raise RuntimeError( 50 | "The environment variable QT_API has the unrecognized value {!r}; " 51 | "valid values are {}".format(QT_API_ENV, ", ".join(_ETS)) 52 | ) 53 | 54 | 55 | def _setup_pyqt5plus(): 56 | global QtCore, QtWidgets, QtGui, __version__ 57 | global _to_int 58 | 59 | if QT_API == QT_API_PYQT6: 60 | from PyQt6 import QtCore, QtWidgets, QtGui 61 | 62 | __version__ = QtCore.PYQT_VERSION_STR 63 | _to_int = operator.attrgetter("value") 64 | elif QT_API == QT_API_PYSIDE6: 65 | from PySide6 import QtCore, QtWidgets, QtGui, __version__ 66 | 67 | if parse_version(__version__) >= parse_version("6.4"): 68 | _to_int = operator.attrgetter("value") 69 | else: 70 | _to_int = int 71 | elif QT_API == QT_API_PYQT5: 72 | from PyQt5 import QtCore, QtWidgets, QtGui 73 | 74 | __version__ = QtCore.PYQT_VERSION_STR 75 | _to_int = int 76 | elif QT_API == QT_API_PYSIDE2: 77 | from PySide2 import QtCore, QtWidgets, QtGui, __version__ 78 | 79 | _to_int = int 80 | else: 81 | raise AssertionError(f"Unexpected QT_API: {QT_API}") 82 | 83 | 84 | if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]: 85 | _setup_pyqt5plus() 86 | elif QT_API is None: # See above re: dict.__getitem__. 87 | _candidates = [ 88 | (_setup_pyqt5plus, QT_API_PYQT6), 89 | (_setup_pyqt5plus, QT_API_PYSIDE6), 90 | (_setup_pyqt5plus, QT_API_PYQT5), 91 | (_setup_pyqt5plus, QT_API_PYSIDE2), 92 | ] 93 | for _setup, QT_API in _candidates: 94 | try: 95 | _setup() 96 | except ImportError: 97 | continue 98 | break 99 | else: 100 | raise ImportError( 101 | "Failed to import any of the following Qt binding modules: {}".format( 102 | ", ".join([QT_API for _, QT_API in _candidates]) 103 | ) 104 | ) 105 | else: # We should not get there. 106 | raise AssertionError(f"Unexpected QT_API: {QT_API}") 107 | _version_info = tuple(QtCore.QLibraryInfo.version().segments()) 108 | 109 | 110 | if _version_info < (5, 12): 111 | raise ImportError( 112 | f"The Qt version imported is " 113 | f"{QtCore.QLibraryInfo.version().toString()} but Matplotlib requires " 114 | f"Qt>=5.12" 115 | ) 116 | 117 | 118 | # Fixes issues with Big Sur 119 | # https://bugreports.qt.io/browse/QTBUG-87014, fixed in qt 5.15.2 120 | if ( 121 | sys.platform == "darwin" 122 | and parse_version(platform.mac_ver()[0]) >= parse_version("10.16") 123 | and _version_info < (5, 15, 2) 124 | ): 125 | os.environ.setdefault("QT_MAC_WANTS_LAYER", "1") 126 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/qt/assets/FFmpeg_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | `ffmpeg-downloader`: Python FFmpeg release build downloader 2 | =========================================================== 3 | 4 | |pypi| |pypi-status| |pypi-pyvers| |github-license| |github-status| 5 | 6 | .. |pypi| image:: https://img.shields.io/pypi/v/ffmpeg-downloader 7 | :alt: PyPI 8 | .. |pypi-status| image:: https://img.shields.io/pypi/status/ffmpeg-downloader 9 | :alt: PyPI - Status 10 | .. |pypi-pyvers| image:: https://img.shields.io/pypi/pyversions/ffmpeg-downloader 11 | :alt: PyPI - Python Version 12 | .. |github-license| image:: https://img.shields.io/github/license/python-ffmpegio/python-ffmpeg-downloader 13 | :alt: GitHub License 14 | .. |github-status| image:: https://img.shields.io/github/actions/workflow/status/python-ffmpegio/python-ffmpeg-downloader/test_n_pub.yml?branch=main 15 | :alt: GitHub Workflow Status 16 | 17 | Python `ffmpeg-downloader` package automatically downloads the latest FFmpeg prebuilt binaries for Windows, Linux, & MacOS. 18 | It's cli interface mimics that of `pip` to install, uninstall, list, search, and download available FFmpeg versions. This package 19 | is ideal for those who: 20 | 21 | - Use the git snapshot version of FFmpeg 22 | - Are in Windows environment 23 | 24 | Those who intend to use a release version in Linux and MacOS are encouraged to install via the OS package manager 25 | (e.g., `apt-get` for Ubuntu and `brew` for MacOS). 26 | 27 | The FFmpeg builds will be downloaded from 3rd party hosts: 28 | 29 | ======= ========================================================================== 30 | Windows `https://www.gyan.dev/ffmpeg/builds `_ 31 | Linux `https://johnvansickle.com/ffmpeg `_ 32 | MacOS `https://evermeet.cx/ffmpeg `_ 33 | ======= ========================================================================== 34 | 35 | If you appreciate their effort to build and host these builds, please consider donating on their websites. 36 | 37 | Installation 38 | ------------ 39 | 40 | .. code-block:: bash 41 | 42 | pip install ffmpeg-downloader 43 | 44 | Console Commands 45 | ---------------- 46 | 47 | In cli, use `ffdl` command after the package is installed. Alternately, you can call the module by 48 | `python -m ffmpeg_downloader`. For full help, run: 49 | 50 | .. code-block:: 51 | 52 | ffdl -h 53 | 54 | To download and install FFmpeg binaries 55 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 56 | 57 | .. code-block:: bash 58 | 59 | ffdl install 60 | 61 | This command downloads and installs the latest FFmpeg **release**. You will see the progress messages 62 | similar to the following: 63 | 64 | .. code-block:: bash 65 | 66 | Collecting ffmpeg 67 | Using cached ffmpeg-5.1.2-essentials_build.zip (79 MB) 68 | Installing collected FFmpeg binaries: 5.1.2@essentials 69 | Successfully installed FFmpeg binaries: 5.1.2@essentials in 70 | C:\Users\User\AppData\Local\ffmpegio\ffmpeg-downloader\ffmpeg\bin 71 | 72 | In Linux, symlinks fo the installed binaries are automatically created in `~/.local/bin` or `~/bin` 73 | so the FFmpeg commands are immediately available (only if one of these directories already exists). 74 | 75 | In Windows and MacOS, the binary folder can be added to the system path by `--add-path` option: 76 | 77 | .. code-block:: bash 78 | 79 | ffdl install --add-path 80 | 81 | The new system paths won't be applied to the current console window. You may need to close and reopen 82 | or possibly log out and log back in for the change to take effect. 83 | 84 | To install the latest git master snapshot build: 85 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 86 | 87 | .. code-block:: bash 88 | 89 | ffdl install snapshot 90 | 91 | To list or search available release versions: 92 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 93 | 94 | Use ``list`` and ``search`` commands. 95 | 96 | .. code-block:: bash 97 | 98 | ffdl list # lists all available releases 99 | 100 | ffdl search 5 # lists all v5 releases 101 | 102 | 103 | To specify a release version: 104 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 105 | 106 | Add version number as the last argument of the command: 107 | 108 | .. code-block:: bash 109 | 110 | ffdl install 4.4 111 | 112 | Additionally, there are multiple options for each build for the Windows builds: 113 | 114 | =============== =========================================================================== 115 | ``essentials`` Built only with commonly used third-party libraries (default option) 116 | ``full`` Built with the most third-party libraries 117 | ``full-shared`` Same as ``full`` but separate shared libraries (DLLs) and development files 118 | (release builds only) 119 | =============== =========================================================================== 120 | 121 | Visit `gyan.dev `_ for more information. 122 | To specify which flavor to install, use ``@`` 123 | 124 | .. code-block:: bash 125 | 126 | ffdl install snapshot@full # full build of latest snapshot 127 | ffdl install 5.2@full-shared # full build of v5.2 128 | 129 | To update or change version if available 130 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 131 | 132 | Like ``pip``, use ``-U`` or ``--upgrade`` flag 133 | 134 | .. code-block:: bash 135 | 136 | ffdl install -U 137 | 138 | To uninstall 139 | ^^^^^^^^^^^^ 140 | 141 | .. code-block:: bash 142 | 143 | ffdl uninstall 144 | 145 | In Python 146 | --------- 147 | 148 | This package has the following useful attributes: 149 | 150 | .. code-block:: python 151 | 152 | import ffmpeg_downloader as ffdl 153 | 154 | ffdl.ffmpeg_dir # FFmpeg binaries directory 155 | ffdl.ffmpeg_version # version string of the intalled FFmpeg 156 | ffdl.ffmpeg_path # full path of the FFmpeg binary 157 | ffdl.ffprobe_path # full path of the FFprobe binary 158 | ffdl.ffplay_path # full path of the FFplay binary 159 | 160 | 161 | The ``ffxxx_path`` attributes are useful to call FFxxx command with ``subprocess``: 162 | 163 | .. code-block:: python 164 | 165 | # To call FFmpeg via subprocess 166 | import subprocess as sp 167 | 168 | sp.run([ffdl.ffmpeg_path, '-i', 'input.mp4', 'output.mkv']) 169 | 170 | Meanwhile, there are many FFmpeg wrapper packages which do not let you specify the 171 | FFmpeg path or cumbersome to do so. If installing the FFmpeg with ``--add-path`` option is 172 | not preferable, use `ffmpeg_downloader.add_path()` function to make the binaries available 173 | to these packages. 174 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/_linux.py: -------------------------------------------------------------------------------- 1 | from shutil import copyfileobj 2 | from packaging.version import Version 3 | import re, tarfile, os 4 | from os import path 5 | import platform 6 | 7 | 8 | from ._config import Config 9 | from ._download_helper import download_info, chmod 10 | 11 | home_url = "https://johnvansickle.com/ffmpeg" 12 | 13 | # last one gets picked 14 | asset_priority = ["amd64", "i686", "arm64", "armhf", "armel"] 15 | mapping = { 16 | "aarch64": "arm64" 17 | } 18 | 19 | # place the matching cpu arch first (need test) 20 | arch = platform.machine() 21 | arch = mapping.get(arch, arch) # resolve alias 22 | try: 23 | # TODO - adjust as feedback for different architecture is provided 24 | i = asset_priority.index(arch) 25 | if i: 26 | asset_priority = [ 27 | asset_priority[i], 28 | *asset_priority[:i], 29 | *asset_priority[i + 1 :], 30 | ] 31 | except: 32 | pass 33 | 34 | 35 | def are_assets_options(): 36 | return False 37 | 38 | 39 | def get_latest_version(proxy=None, retries=None, timeout=None): 40 | readme = download_info( 41 | f"{home_url}/release-readme.txt", 42 | {"Accept": "text/plain"}, 43 | proxy=proxy, 44 | retries=retries, 45 | timeout=timeout, 46 | ).text 47 | return Version(re.search(r"version: (\d+\.\d+(?:\.\d+)?)", readme)[1]) 48 | 49 | 50 | def get_latest_snapshot(proxy=None, retries=None, timeout=None): 51 | 52 | readme = download_info( 53 | f"{home_url}/git-readme.txt", 54 | {"Accept": "text/plain"}, 55 | proxy=proxy, 56 | retries=retries, 57 | timeout=timeout, 58 | ).text 59 | 60 | hash_str = re.search(r"version: (.+)", readme)[1][:10] 61 | amd64_build = re.search(r"build: (.+)", readme)[1] 62 | build_date = re.match(r"ffmpeg-(git-\d{8})-amd64-static.tar.xz", amd64_build)[1] 63 | 64 | config = Config() 65 | snapshot = config.snapshot 66 | if hash_str not in snapshot: 67 | 68 | config.snapshot = { 69 | hash_str: { 70 | arch: { 71 | "name": f"ffmpeg-{build_date}-{arch}-static.tar.xz", 72 | "url": f"{home_url}/builds/ffmpeg-git-{arch}-static.tar.xz", 73 | } 74 | for arch in asset_priority 75 | } 76 | } 77 | 78 | return hash_str 79 | 80 | 81 | def retrieve_old_releases(proxy=None, retries=None, timeout=None): 82 | url_or = f"{home_url}/old-releases" 83 | r = download_info( 84 | url_or, 85 | # {"Accept": "text/plain"}, 86 | proxy=proxy, 87 | retries=retries, 88 | timeout=timeout, 89 | ) 90 | matches = re.findall( 91 | rf'\ffmpeg-(.+?)(?:-64bit|-({"|".join(asset_priority)})(?:-64bit)?)-static.tar.xz\', 92 | r.text, 93 | ) 94 | releases = {} 95 | for url, ver, arch in matches: 96 | ver = Version(ver) 97 | rel = releases.get(ver, None) 98 | if rel is None: 99 | releases[ver] = {arch or "amd64": {"name": url, "url": f"{url_or}/{url}"}} 100 | else: 101 | rel[arch or "amd64"] = {"name": url, "url": f"{url_or}/{url}"} 102 | return releases 103 | 104 | 105 | def update_releases_info(force=None, proxy=None, retries=None, timeout=None): 106 | 107 | config = Config() 108 | releases = {} if force else config.releases 109 | 110 | ver = get_latest_version(proxy, retries, timeout) 111 | if ver in releases: 112 | return 113 | 114 | # update the release list 115 | releases = { 116 | ver: { 117 | arch: { 118 | "name": f"ffmpeg-{ver}-{arch}-static.tar.xz", 119 | "url": f"{home_url}/releases/ffmpeg-release-{arch}-static.tar.xz", 120 | } 121 | for arch in asset_priority 122 | }, 123 | **retrieve_old_releases(proxy=proxy, retries=retries, timeout=timeout), 124 | } 125 | 126 | # update the releases data in the config 127 | config.releases = releases 128 | config.dump() 129 | 130 | 131 | def version_sort_key(version): 132 | v, o = version 133 | k = v.major * 10000 + v.minor * 1000 + v.micro * 10 if type(v) == Version else 0 134 | return k 135 | 136 | 137 | def get_download_info(version, option): 138 | asset = getattr(Config(), "releases" if type(version) == Version else "snapshot")[ 139 | version 140 | ][option or asset_priority[0]] 141 | return [ 142 | [ 143 | asset["name"], 144 | asset["url"], 145 | "application/x-xz", 146 | None, 147 | ] 148 | ] 149 | 150 | 151 | def is_within_directory(directory, target): 152 | 153 | abs_directory = path.abspath(directory) 154 | abs_target = path.abspath(target) 155 | 156 | prefix = path.commonprefix([abs_directory, abs_target]) 157 | 158 | return prefix == abs_directory 159 | 160 | 161 | def extract(tarpaths, dst, progress=None): 162 | 163 | tarpath = tarpaths[0] 164 | 165 | with tarfile.open(tarpath, "r") as f: 166 | 167 | sz = 0 168 | for member in f.getmembers(): 169 | member_path = os.path.join(tarpath, member.name) 170 | if not is_within_directory(tarpath, member_path): 171 | raise Exception("Attempted Path Traversal in Tar File") 172 | sz += member.size 173 | 174 | if progress is None: 175 | f.extractall(dst) 176 | else: 177 | progress = progress(sz) 178 | for member in f.getmembers(): 179 | if member.isfile(): 180 | with open(path.join(dst, member.name), "wb") as fo: 181 | copyfileobj(progress.io_wrapper(f.extractfile(member)), fo) 182 | else: 183 | f.extract(member, dst) 184 | 185 | # grab the extracted folder name 186 | dstsub = os.listdir(dst)[0] 187 | 188 | # make sure binaries are executable 189 | for name in ("ffmpeg", "ffprobe"): 190 | chmod(path.join(dst, dstsub, name)) 191 | 192 | return dstsub 193 | 194 | 195 | def set_symlinks(binpaths): 196 | 197 | user_home = path.expanduser("~") 198 | user_bindirs = [path.join(user_home, ".local", "bin"), path.join(user_home, "bin")] 199 | d = next( 200 | (d for d in user_bindirs if path.isdir(d)), 201 | None, 202 | ) 203 | if d is None: 204 | d = user_bindirs[0] 205 | os.makedirs(d, exist_ok=True) 206 | print( 207 | "!!!Created ~/.local/bin. Must log out and back in for the setting to take effect (or update .profile or .bashrc).!!!" 208 | ) 209 | 210 | symlinks = {name: path.join(d, name) for name in binpaths} 211 | for name, binpath in binpaths.items(): 212 | err = True 213 | if path.isfile(binpath): # ffplay is not included 214 | try: 215 | os.symlink(binpath, symlinks[name]) 216 | err = False 217 | except FileExistsError: 218 | # already symlinked (or file placed by somebody else) 219 | pass 220 | if err: 221 | del symlinks[name] 222 | return symlinks 223 | 224 | 225 | def clr_symlinks(symlinks): 226 | for link in symlinks.values(): 227 | os.unlink(link) 228 | 229 | 230 | def set_env_path(dir): 231 | raise NotImplementedError( 232 | "--add-path option is not supported in Linux (Automatically added via symlink. Use --no-symlinks to disable.)." 233 | ) 234 | 235 | 236 | def clr_env_path(dir): 237 | pass 238 | 239 | 240 | def set_env_vars(vars, bindir): 241 | raise NotImplementedError("--set-env option is not supported in Linux.") 242 | 243 | 244 | def clr_env_vars(vars): 245 | pass 246 | 247 | 248 | def get_bindir(install_dir): 249 | return path.join(install_dir, "ffmpeg") 250 | 251 | 252 | def get_binpath(install_dir, app): 253 | return path.join(install_dir, "ffmpeg", app) 254 | 255 | 256 | def parse_version(ver_line, basedir): 257 | m = re.match(r"ffmpeg version N-.{5}-g([a-z0-9]{10})", ver_line) 258 | if m: 259 | return (m[1], None) 260 | else: 261 | m = re.match(r"ffmpeg version (.+?)-", ver_line) 262 | ver = m[1] 263 | try: 264 | ver = Version(ver) 265 | except: 266 | pass 267 | return (ver, None) 268 | return None 269 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/_macos.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | import re, os, zipfile 3 | 4 | from shutil import copyfileobj 5 | from packaging.version import Version 6 | 7 | from ._download_helper import download_info, chmod 8 | from ._config import Config 9 | 10 | home_url = "https://evermeet.cx" 11 | 12 | 13 | def are_assets_options(): 14 | return False 15 | 16 | 17 | def get_latest_version(proxy=None, retries=None, timeout=None): 18 | 19 | return Version( 20 | download_info( 21 | f"{home_url}/ffmpeg/info/ffmpeg/release", 22 | {"Accept": "application/json"}, 23 | proxy=proxy, 24 | retries=retries, 25 | timeout=timeout, 26 | ).json()["version"] 27 | ) 28 | 29 | 30 | def get_latest_snapshot(proxy=None, retries=None, timeout=None): 31 | 32 | json = download_info( 33 | f"{home_url}/ffmpeg/info/ffmpeg/snapshot", 34 | {"Accept": "application/json"}, 35 | proxy=proxy, 36 | retries=retries, 37 | timeout=timeout, 38 | ).json() 39 | 40 | ver = json["version"] 41 | 42 | config = Config() 43 | snapshot = config.snapshot 44 | if ver not in snapshot: 45 | get_asset = lambda json: { 46 | "name": path.basename(json["download"]["zip"]["url"]), 47 | "url": json["download"]["zip"]["url"], 48 | "size_str": f"{round(json['download']['zip']['size']//(1024**2))}M", 49 | } 50 | assets = {"ffmpeg": get_asset(json)} 51 | config.snapshot = {ver: assets} 52 | 53 | for app in ("ffprobe", "ffplay"): 54 | assets[app] = get_asset( 55 | download_info( 56 | f"{home_url}/ffmpeg/info/{app}/snapshot", 57 | {"Accept": "application/json"}, 58 | proxy=proxy, 59 | retries=retries, 60 | timeout=timeout, 61 | ).json() 62 | ) 63 | 64 | return ver 65 | 66 | 67 | def retrieve_release_list(app, proxy=None, retries=None, timeout=None): 68 | base_url = f"{home_url}/pub/{app}" 69 | r = download_info( 70 | base_url, 71 | {"Accept": "text/html"}, 72 | proxy=proxy, 73 | retries=retries, 74 | timeout=timeout, 75 | ) 76 | return base_url, re.findall( 77 | rf'\\1\.+?(\d+M)', r.text 78 | ) 79 | 80 | 81 | def update_releases_info(force=None, proxy=None, retries=None, timeout=None): 82 | 83 | config = Config() 84 | releases = {} if force else config.releases 85 | 86 | base_url, assets = retrieve_release_list( 87 | "ffmpeg", proxy=proxy, retries=retries, timeout=timeout 88 | ) 89 | 90 | if all(Version(ver) in releases for file, ver, size_str in assets): 91 | return releases 92 | 93 | # update the release list 94 | releases = { 95 | ver: { 96 | "ffmpeg": {"name": file, "url": f"{base_url}/{file}", "size_str": size_str} 97 | } 98 | for file, ver, size_str in assets 99 | } 100 | 101 | for app in ("ffprobe", "ffplay"): 102 | base_url, assets = retrieve_release_list( 103 | app, proxy=proxy, retries=retries, timeout=timeout 104 | ) 105 | for file, ver, size_str in assets: 106 | # update the release list 107 | releases[ver][app] = { 108 | "name": file, 109 | "url": f"{base_url}/{file}", 110 | "size": size_str, 111 | } 112 | 113 | # convert the version strings to Version object 114 | releases = {Version(ver): asset for ver, asset in releases.items()} 115 | 116 | # update the releases data in the config 117 | config.releases = releases 118 | config.dump() 119 | 120 | 121 | def version_sort_key(version): 122 | v, _ = version 123 | return v 124 | 125 | 126 | def get_download_info(version, option): 127 | assets = getattr(Config(), "releases" if type(version) == Version else "snapshot")[ 128 | version 129 | ] 130 | return [[v["name"], v["url"], "application/zip", None] for v in assets.values()] 131 | 132 | 133 | def extract(zippaths, dst, progress=None): 134 | 135 | fzips = [] 136 | try: 137 | for zippath in zippaths: 138 | fzips.append(zipfile.ZipFile(zippath, "r")) 139 | 140 | if progress is None: 141 | for f in fzips: 142 | f.extractall(dst) 143 | else: 144 | progress = progress( 145 | sum(getattr(i, "file_size", 0) for f in fzips for i in f.infolist()) 146 | ) 147 | for f in fzips: 148 | for i in f.infolist(): 149 | if getattr(i, "file_size", 0): # file 150 | with f.open(i) as fi, open( 151 | path.join(dst, i.filename), "wb" 152 | ) as fo: 153 | copyfileobj(progress.io_wrapper(fi), fo) 154 | else: 155 | f.extract(i, dst) 156 | finally: 157 | for f in fzips: 158 | f.close() 159 | 160 | # make sure binaries are executable 161 | for name in ("ffmpeg", "ffprobe", "ffplay"): 162 | chmod(path.join(dst, name)) 163 | 164 | return dst 165 | 166 | 167 | def set_symlinks(binpaths): 168 | raise NotImplementedError("--set_symlinks option is not supported on Mac") 169 | 170 | 171 | def clr_symlinks(symlinks): 172 | pass 173 | 174 | 175 | def get_profile(): 176 | file = ".bash_profile" if os.environ['SHELL'] == "/bin/bash" else ".zsh_profile" 177 | return path.join(path.expanduser("~"), file) 178 | 179 | 180 | def append_envvar(name, value): 181 | filename = get_profile() 182 | with open(filename, "at") as f: 183 | f.write(f'export {name}="{value}"\n') 184 | 185 | 186 | def get_envvar(name): 187 | filename = get_profile() 188 | print(filename) 189 | try: 190 | with open(filename, "rt") as f: 191 | lines = f.readlines() 192 | except: 193 | lines = [] 194 | i = next((i for i, l in enumerate(lines) if l.startswith(f"export {name}=")), -1) 195 | if i < 0: 196 | return "" 197 | value = lines[i].split("=", 1)[1][:-1] 198 | return value[1:-1] if value[0] == '"' else value 199 | 200 | 201 | def set_envvar(name, value): 202 | filename = get_profile() 203 | try: 204 | with open(filename, "rt") as f: 205 | lines = f.readlines() 206 | except: 207 | lines = [] 208 | 209 | i = next((i for i, l in enumerate(lines) if l.startswith(f"export {name}=")), -1) 210 | if i >= 0: 211 | lines.pop(i) 212 | if value is not None: 213 | lines.append(f'export {name}="{value}"\n') 214 | 215 | with open(filename, "wt") as f: 216 | f.writelines(lines) 217 | 218 | 219 | def set_env_path(dir): 220 | 221 | user_path = get_envvar("PATH") 222 | dirs = user_path.split(os.pathsep) if user_path else [] 223 | if dir not in dirs: 224 | dirs = [dir, *dirs] 225 | if "${PATH}" not in dirs: 226 | dirs.append("${PATH}") 227 | set_envvar("PATH", os.pathsep.join(dirs)) 228 | 229 | 230 | def clr_env_path(dir): 231 | user_path = get_envvar("PATH") 232 | dirs = user_path.split(os.pathsep) if user_path else [] 233 | if dir in dirs: 234 | if len(dirs) > 2: 235 | dirs.remove(dir) 236 | set_envvar("PATH", os.pathsep.join(dirs)) 237 | else: 238 | set_envvar("PATH", None) 239 | 240 | 241 | def set_env_vars(vars, bindir): 242 | raise NotImplementedError("--set_symlinks option is not supported on Mac") 243 | 244 | 245 | def clr_env_vars(vars): 246 | pass 247 | 248 | def get_bindir(install_dir): 249 | return path.join(install_dir, "ffmpeg") 250 | 251 | 252 | def get_binpath(install_dir, app): 253 | return path.join(install_dir, "ffmpeg", app) 254 | 255 | 256 | def parse_version(ver_line, basedir): 257 | m = re.match(r"ffmpeg version (.+)-tessus", ver_line) 258 | if m: 259 | ver = m[1] 260 | try: 261 | ver = Version(ver) 262 | except: 263 | m = re.match(r"N-(.{6}-g[a-z0-9]{10})", ver) 264 | try: 265 | ver = m[1] 266 | except: 267 | print(f'Unknown version "{ver}" found') 268 | 269 | return ver, None 270 | else: 271 | return None 272 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/_win32.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from shutil import copyfileobj 3 | from os import path 4 | import subprocess as sp 5 | import zipfile 6 | from datetime import datetime 7 | from packaging.version import Version 8 | import re 9 | from glob import glob 10 | 11 | import winreg 12 | import os 13 | 14 | 15 | from ._config import Config 16 | from ._download_helper import download_info 17 | 18 | home_url = "https://www.gyan.dev/ffmpeg/builds" 19 | 20 | asset_type = ( 21 | "application/x-zip-compressed" # 'content_type': 'application/octet-stream' for .7z 22 | ) 23 | 24 | asset_names = { 25 | "essentials_build": "essentials", 26 | "full_build": "full", 27 | "shared": "full-shared", 28 | } 29 | 30 | # last one gets picked 31 | asset_priority = ["full-shared", "full", "essentials"] 32 | 33 | 34 | def are_assets_options(): 35 | return True 36 | 37 | 38 | def get_latest_version(proxy=None, retries=None, timeout=None): 39 | return Version( 40 | download_info( 41 | f"{home_url}/ffmpeg-release-essentials.7z.ver", 42 | {"Accept": "text/plain"}, 43 | proxy=proxy, 44 | retries=retries, 45 | timeout=timeout, 46 | ).text 47 | ) 48 | 49 | 50 | def get_latest_snapshot(proxy=None, retries=None, timeout=None): 51 | ver = download_info( 52 | f"{home_url}/ffmpeg-git-essentials.7z.ver", 53 | {"Accept": "text/plain"}, 54 | proxy=proxy, 55 | retries=retries, 56 | timeout=timeout, 57 | ).text 58 | 59 | config = Config() 60 | snapshot = config.snapshot 61 | if ver not in snapshot: 62 | page = 1 63 | assets, eol = retrieve_releases_page(page, ver, 100, proxy, retries, timeout) 64 | while assets is None and not eol: 65 | page += 1 66 | assets, eol = retrieve_releases_page( 67 | page, ver, 100, proxy, retries, timeout 68 | ) 69 | if assets is None: 70 | raise ValueError(f"Assets for snapshot {ver} could not be located.") 71 | config = Config() 72 | config.snapshot = {ver: assets} 73 | 74 | return ver 75 | 76 | 77 | def check_rate_limit(proxy=None, retries=None, timeout=None): 78 | try: 79 | r = download_info( 80 | "https://api.github.com/rate_limit", 81 | {"Accept": "application/vnd.github+json"}, 82 | proxy=proxy, 83 | retries=retries, 84 | timeout=timeout, 85 | ) 86 | status = r.json()["resources"]["core"] 87 | assert status["remaining"] == 0 88 | return f"You've reached the access rate limit on GitHub. Wait till {datetime.fromtimestamp(status['reset'])} and try again." 89 | except: 90 | return 0 91 | 92 | 93 | def retrieve_releases_page( 94 | page, snapshot=None, per_page=100, proxy=None, retries=None, timeout=None 95 | ): 96 | headers = {"Accept": "application/vnd.github+json"} 97 | url = "https://api.github.com/repos/GyanD/codexffmpeg/releases" 98 | 99 | r = download_info( 100 | url, 101 | headers=headers, 102 | params={"page": page, "per_page": per_page}, 103 | proxy=proxy, 104 | retries=retries, 105 | timeout=timeout, 106 | ) 107 | 108 | if r.status_code != 200: 109 | raise ConnectionRefusedError( 110 | check_rate_limit(proxy=proxy, retries=retries, timeout=timeout) 111 | or "Failed to retrieve data from GitHub" 112 | ) 113 | 114 | info = r.json() 115 | 116 | extract_assets = lambda itm: { 117 | asset_names.get( 118 | elm["name"].rsplit(".", 1)[0].rsplit("-", 1)[-1], elm["name"] 119 | ): {k: elm[k] for k in ("name", "browser_download_url", "content_type", "size")} 120 | for elm in itm 121 | if elm["content_type"] == asset_type 122 | } 123 | 124 | if snapshot: 125 | return next( 126 | ( 127 | extract_assets(rel["assets"]) 128 | for rel in info 129 | if rel["tag_name"] == snapshot 130 | ), 131 | None, 132 | ), not len(info) 133 | else: 134 | return { 135 | tag: url 136 | for tag, url in ( 137 | (Version(rel["tag_name"]), extract_assets(rel["assets"])) 138 | for rel in info 139 | if re.match(r"\d+\.\d+(?:\.\d+)?$", rel["tag_name"]) 140 | ) 141 | if tag 142 | }, not len(info) 143 | 144 | 145 | def update_releases_info(force=None, proxy=None, retries=None, timeout=None): 146 | config = Config() 147 | releases = {} if force else config.releases 148 | changed = False 149 | 150 | # update the release list 151 | for page in itertools.count(1): 152 | # process the release data found on the page 153 | reldata, eol = retrieve_releases_page( 154 | page, proxy=proxy, retries=retries, timeout=timeout 155 | ) 156 | 157 | # check if any info is already in the config 158 | found = next((rel in releases for rel in reldata), False) 159 | 160 | # update the release data in the config 161 | if len(reldata): 162 | releases.update(reldata) 163 | changed = True 164 | 165 | # if any is already in or no more data, exit the loop 166 | if found or eol: 167 | break 168 | 169 | # update the releases data in the config 170 | if changed: 171 | config.releases = releases 172 | config.dump() 173 | 174 | 175 | def version_sort_key(version): 176 | v, o = version 177 | k = v.major * 10000 + v.minor * 1000 + v.micro * 10 if type(v) == Version else 0 178 | return k + asset_priority.index(o) 179 | 180 | 181 | def get_download_info(version, option): 182 | asset = getattr(Config(), "releases" if type(version) == Version else "snapshot")[ 183 | version 184 | ][option] 185 | return [ 186 | [ 187 | asset["name"], 188 | asset["browser_download_url"], 189 | asset["content_type"], 190 | int(asset["size"]), 191 | ] 192 | ] 193 | 194 | 195 | def extract(zippaths, dst, progress=None): 196 | zippath = zippaths[0] 197 | 198 | with zipfile.ZipFile(zippath, "r") as f: 199 | if progress is None: 200 | f.extractall(dst) 201 | else: 202 | progress = progress(sum(getattr(i, "file_size", 0) for i in f.infolist())) 203 | for i in f.infolist(): 204 | if not getattr(i, "file_size", 0): # directory 205 | f.extract(i, dst) 206 | else: 207 | with f.open(i) as fi, open(path.join(dst, i.filename), "wb") as fo: 208 | copyfileobj(progress.io_wrapper(fi), fo) 209 | 210 | return os.listdir(dst)[0] 211 | 212 | 213 | def set_symlinks(binpaths): 214 | raise NotImplementedError("--set_symlinks option is not supported on Windows") 215 | 216 | 217 | def clr_symlinks(symlinks): 218 | pass 219 | 220 | 221 | env_keys = winreg.HKEY_CURRENT_USER, "Environment" 222 | 223 | 224 | def get_env(name): 225 | try: 226 | with winreg.OpenKey(*env_keys, 0, winreg.KEY_READ) as key: 227 | return winreg.QueryValueEx(key, name)[0] 228 | except FileNotFoundError: 229 | return "" 230 | 231 | 232 | def set_env_path(dir): 233 | user_path = get_env("Path") 234 | if dir not in user_path: 235 | sp.run(["setx", "Path", user_path + os.pathsep + dir], stdout=sp.DEVNULL) 236 | 237 | 238 | def clr_env_path(dir): 239 | user_path = get_env("Path") 240 | parts = user_path.split(os.pathsep + dir) 241 | if len(parts) > 1: 242 | sp.run(["setx", "Path", "".join(parts)], stdout=sp.DEVNULL) 243 | 244 | 245 | def set_env_vars(vars, bindir): 246 | for k, v in vars.items(): 247 | if get_env(k) != v: 248 | sp.run( 249 | f'setx {k} {bindir if v == "path" else get_binpath(bindir, v)}', 250 | stdout=sp.DEVNULL, 251 | ) 252 | 253 | 254 | def clr_env_vars(vars): 255 | with winreg.OpenKey(*env_keys, 0, winreg.KEY_ALL_ACCESS) as key: 256 | for name in vars: 257 | try: 258 | winreg.DeleteValue(key, name) 259 | except FileNotFoundError: 260 | pass 261 | 262 | 263 | def get_bindir(install_dir): 264 | return path.join(install_dir, "ffmpeg", "bin") 265 | 266 | 267 | def get_binpath(install_dir, app): 268 | return path.join(install_dir, "ffmpeg", "bin", app + ".exe") 269 | 270 | 271 | def parse_version(ver_line, basedir): 272 | m = re.match(r"ffmpeg version (.+)-www\.gyan\.dev", ver_line) 273 | if m: 274 | ver, opt = m[1].rsplit("-", 1) 275 | try: 276 | ver = Version(ver) 277 | except: 278 | pass 279 | 280 | if opt == "full_build" and len( 281 | glob(path.join(basedir, "ffmpeg", "bin", "av*.dll")) 282 | ): 283 | opt = "shared" 284 | 285 | return ver, asset_names[opt] 286 | else: 287 | return None 288 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/_backend.py: -------------------------------------------------------------------------------- 1 | from os import listdir, path, name as os_name, getcwd, makedirs 2 | from shutil import rmtree, move, copyfile 3 | import sys 4 | from platformdirs import user_data_dir 5 | from packaging.version import Version 6 | from tempfile import TemporaryDirectory, mkdtemp 7 | import subprocess as sp 8 | 9 | from ._config import Config 10 | from ._download_helper import download_file 11 | 12 | if os_name == "nt": 13 | from . import _win32 as _ 14 | elif sys.platform == "darwin": 15 | from . import _macos as _ 16 | else: 17 | from . import _linux as _ 18 | 19 | home_url = _.home_url 20 | 21 | disclaimer_text = f""" 22 | You are about to download the latest FFmpeg release build from {home_url}. 23 | Proceeding to download the file is done at your own discretion and risk and 24 | with agreement that you will be solely responsible for any damage to your 25 | computer system or loss of data that results from such activities. 26 | 27 | Do you wish to proceed to download? [Yn] """ 28 | 29 | donation_text = f"""Please remember that to maintain and host the FFmpeg binaries is not free. 30 | If you appreciate their effort, please consider donating to help them with 31 | the upkeep of their website via {home_url}. 32 | """ 33 | 34 | preset_env_vars = { 35 | "imageio": {"IMAGEIO_FFMPEG_EXE": "ffmpeg"}, 36 | "moviepy": {"FFMPEG_BINARY": "ffmpeg"}, 37 | } 38 | 39 | 40 | def parse_version(version_spec): 41 | # 5.1.2 or 5.1.2@essential or 5.1.2@arm 42 | 43 | ver, *opts = version_spec.split("@", 1) 44 | if ver == "snapshot": 45 | ver = _.get_latest_snapshot() 46 | elif ver == "release": 47 | ver = _.get_latest_version() 48 | elif ver: 49 | ver = Version(ver) 50 | return ver, opts[0] if len(opts) else None 51 | 52 | 53 | def list(force=None, proxy=None, retries=None, timeout=None): 54 | config = Config() 55 | if force or config.is_stale(): 56 | _.update_releases_info(force, proxy, retries, timeout) 57 | return ( 58 | [(rel, asset) for rel, assets in config.releases.items() for asset in assets] 59 | if _.are_assets_options() 60 | else [(rel, None) for rel, _ in config.releases.items()] 61 | ) 62 | 63 | 64 | def search( 65 | version_spec, auto_select=None, force=None, proxy=None, retries=None, timeout=None 66 | ): 67 | version, option = parse_version(version_spec or "release") 68 | config = Config() 69 | 70 | if type(version) == Version: 71 | if force or config.is_stale(): 72 | _.update_releases_info(force, proxy, retries, timeout) 73 | releases = config.releases 74 | 75 | # return the releases starting with the given version 76 | results = ( 77 | [ 78 | (rel, asset) 79 | for rel, assets in releases.items() 80 | if not version or str(rel).startswith(str(version)) 81 | for asset in assets 82 | if not option or option == asset 83 | ] 84 | if _.are_assets_options() 85 | else [ 86 | (rel, None) 87 | for rel, _ in releases.items() 88 | if not version or str(rel).startswith(str(version)) 89 | ] 90 | if option is None 91 | else [] 92 | ) 93 | else: 94 | releases = config.snapshot 95 | # latest info has already been retrieved by parse_version() 96 | 97 | # return the releases starting with the given version 98 | results = ( 99 | [ 100 | (rel, asset) 101 | for rel, assets in releases.items() 102 | for asset in assets 103 | if not option or option == asset 104 | ] 105 | if _.are_assets_options() 106 | else [ 107 | (rel, None) 108 | for rel, _ in releases.items() 109 | if not version or str(rel).startswith(str(version)) 110 | ] 111 | if option is None 112 | else [] 113 | ) 114 | 115 | if auto_select: 116 | # selects the default 117 | return sorted(results, key=_.version_sort_key)[-1] if len(results) else None 118 | 119 | return results 120 | 121 | 122 | def get_dir(): 123 | return user_data_dir("ffmpeg-downloader", "ffmpegio") 124 | 125 | 126 | def cache_dir(): 127 | return path.join(get_dir(), "cache") 128 | 129 | 130 | def bin_dir(): 131 | return _.get_bindir(get_dir()) 132 | 133 | 134 | def cache_list(): 135 | d = cache_dir() 136 | try: 137 | return listdir(d) 138 | except FileNotFoundError: 139 | return [] 140 | 141 | 142 | def gather_download_info(rel, asset, no_cache_dir=None): 143 | # get filename, url, content_type, & size of each install files 144 | info = _.get_download_info(rel, asset) 145 | if no_cache_dir: 146 | return info 147 | 148 | # check if already in cache 149 | return [ 150 | (*item, path.exists(item[-1])) 151 | for item in (((*entry, path.join(cache_dir(), entry[0]))) for entry in info) 152 | ] 153 | 154 | 155 | def download( 156 | info, 157 | dst=None, 158 | progress=None, 159 | no_cache_dir=None, 160 | proxy=None, 161 | retries=None, 162 | timeout=None, 163 | ): 164 | if dst is None: 165 | # save to the current working directory 166 | dst = getcwd() 167 | 168 | with TemporaryDirectory() as tmpdir: 169 | 170 | def do(filename, url, content_type, size, *cache_info): 171 | dstpath = path.join(dst, filename) 172 | 173 | if not no_cache_dir and cache_info[0] != dstpath and path.exists(dstpath): 174 | raise FileExistsError(f"'{dstpath}' already exists") 175 | 176 | # check if already in cache 177 | if no_cache_dir or not cache_info[-1]: 178 | # run downloader 179 | zippath = path.join(tmpdir, filename) 180 | download_file( 181 | zippath, 182 | url, 183 | headers={"Accept": content_type}, 184 | params={}, 185 | progress=progress, 186 | timeout=timeout, 187 | retries=retries, 188 | proxy=proxy, 189 | ) 190 | 191 | # cache the zip file for future use 192 | if not no_cache_dir: 193 | makedirs(path.dirname(cache_info[0]), exist_ok=True) 194 | copyfile(zippath, cache_info[0]) 195 | 196 | # move the downloaded file to the final destination 197 | if no_cache_dir or not cache_info[0].startswith(dst): 198 | move(zippath, dst) 199 | elif cache_info[0] != dstpath: 200 | # if not downloading to the cache dir, copy the file 201 | copyfile(cache_info[0], dstpath) 202 | 203 | return dstpath 204 | 205 | return [do(*entry) for entry in info] 206 | 207 | 208 | def install(*install_files, progress=None): 209 | dir = path.join(get_dir(), "ffmpeg") 210 | tmpdir = mkdtemp() 211 | failed = True 212 | try: 213 | # extract files in temp dir 214 | mvdir = _.extract(install_files, tmpdir, progress) 215 | # delete existing install 216 | if path.exists(dir): 217 | rmtree(dir, ignore_errors=True) 218 | # move the file to the final destination 219 | move(path.join(tmpdir, mvdir) if mvdir else tmpdir, dir) 220 | failed = False 221 | except Exception as e: 222 | if failed: 223 | rmtree(tmpdir, ignore_errors=True) 224 | raise e 225 | 226 | 227 | def remove(remove_all=False, ignore_errors=True): 228 | dir = get_dir() 229 | if not remove_all: 230 | dir = path.join(dir, "ffmpeg") 231 | rmtree(dir, ignore_errors=ignore_errors) 232 | 233 | 234 | def validate_env_vars(env_vars): 235 | # inspect 236 | values = ("path", "ffmpeg", "ffprobe", "ffplay") 237 | for k, v in env_vars.items(): 238 | if not isinstance(k, str) or v not in values: 239 | raise ValueError(f"Environmental variable value must be one of {values}") 240 | 241 | 242 | def presets_to_env_vars(presets, env_vars=None): 243 | if presets is not None: 244 | env_vars = {} if env_vars is None else {**env_vars} 245 | for preset in presets: 246 | try: 247 | env_vars.update(preset_env_vars[preset]) 248 | except: 249 | raise ValueError(f"preset '{preset}' is invalid") 250 | return env_vars 251 | 252 | 253 | def get_env_vars(): 254 | config = Config() 255 | return config.install_setup 256 | 257 | 258 | def set_env_vars(set_path=None, env_vars={}, no_symlinks=False): 259 | # if binaries are in a subdirectory, update dir 260 | bindir = _.get_bindir(get_dir()) 261 | 262 | if set_path: 263 | _.set_env_path(bindir) 264 | 265 | if len(env_vars): 266 | _.set_env_vars(env_vars, bindir) 267 | 268 | symlinks = None 269 | if not no_symlinks: 270 | try: 271 | symlinks = _.set_symlinks( 272 | { 273 | bintype: ffmpeg_path(bintype) 274 | for bintype in ("ffmpeg", "ffprobe", "ffplay") 275 | } 276 | ) 277 | except NotImplementedError: 278 | pass 279 | 280 | # store the environmental variable set 281 | config = Config() 282 | config.install_setup = { 283 | "set_path": bool(set_path), 284 | "env_vars": env_vars, 285 | "symlinks": symlinks, 286 | } 287 | config.dump() 288 | 289 | 290 | def clr_env_vars(): 291 | # get the environmental variable set 292 | config = Config() 293 | setup = config.install_setup 294 | if setup is None: 295 | return 296 | 297 | if "set_path" in setup: 298 | _.clr_env_path(_.get_bindir(get_dir())) 299 | 300 | if "env_vars" in setup: 301 | _.clr_env_vars(setup["env_vars"]) 302 | 303 | if "symlinks" in setup: 304 | _.clr_symlinks(setup["symlinks"]) 305 | 306 | config.install_setup = None 307 | config.dump() 308 | 309 | 310 | def ffmpeg_version(): 311 | basedir = get_dir() 312 | try: 313 | return _.parse_version( 314 | sp.run( 315 | [_.get_binpath(basedir, "ffmpeg"), "-version"], 316 | stdout=sp.PIPE, 317 | stderr=sp.STDOUT, 318 | universal_newlines=True, 319 | ).stdout.split("\n", 1)[0], 320 | basedir, 321 | ) 322 | except: 323 | return None 324 | 325 | 326 | def ffmpeg_path(type=None): 327 | if type: 328 | return _.get_binpath(get_dir(), type) 329 | else: 330 | return _.get_bindir(get_dir()) 331 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse, os 2 | from . import _backend as ffdl 3 | import functools 4 | from tempfile import mkdtemp 5 | from shutil import rmtree 6 | from ._progress import DownloadProgress, InstallProgress 7 | 8 | 9 | def _print_version_table(releases): 10 | if len(releases): 11 | # determine the # of characters 12 | vlen, olen = functools.reduce( 13 | lambda l, r: ( 14 | max(l[0], len(str(r[0]))), 15 | 0 if r[1] is None else max(l[1], len(r[1])), 16 | ), 17 | releases, 18 | (0, 0), 19 | ) 20 | hdr = "Version@Option" if olen else "Version" 21 | w = vlen + 1 + olen 22 | print(hdr) 23 | print("-" * max(w, len(hdr))) 24 | for v, o in releases: 25 | print(f"{v}@{o}" if o else v) 26 | else: 27 | print("No matching version found.") 28 | 29 | 30 | def list_vers(args): 31 | releases = ffdl.list(args.force, args.proxy, args.retries, args.timeout) 32 | _print_version_table(releases) 33 | 34 | 35 | def search(args): 36 | releases = ffdl.search( 37 | args.version, False, args.force, args.proxy, args.retries, args.timeout 38 | ) 39 | _print_version_table(releases) 40 | 41 | 42 | def cache_dir(args): 43 | print(ffdl.cache_dir()) 44 | 45 | 46 | def cache_list(args): 47 | files = ffdl.cache_list() 48 | if len(files): 49 | for f in files: 50 | print(f) 51 | else: 52 | print("no file in cache") 53 | 54 | 55 | def cache_remove(args): 56 | raise NotImplementedError 57 | 58 | 59 | def cache_purge(args): 60 | raise NotImplementedError 61 | 62 | 63 | def download(args): 64 | # select the version/asset 65 | version = ffdl.search( 66 | args.version, True, args.force, args.proxy, args.retries, args.timeout 67 | ) 68 | if version is None: 69 | raise RuntimeError( 70 | f"Version {args.version} of FFmpeg is either invalid or not available prebuilt." 71 | if args.version 72 | else f"No matching version of FFmpeg is found." 73 | ) 74 | 75 | info = ffdl.gather_download_info(*version, args.no_cache_dir) 76 | 77 | if inquire_downloading(info, args): 78 | return # canceled 79 | 80 | dstpaths = ffdl.download( 81 | info, 82 | dst=args.dst, 83 | no_cache_dir=args.no_cache_dir, 84 | proxy=args.proxy, 85 | retries=args.retries, 86 | timeout=args.timeout, 87 | progress=DownloadProgress, 88 | ) 89 | if dstpaths is None: 90 | # exit if user canceled 91 | return 92 | 93 | print(f"Downloaded and saved {args.version}:") 94 | for dstpath in dstpaths: 95 | print(f" {dstpath}") 96 | 97 | 98 | def compose_version_spec(version, option): 99 | return f"{version}@{option}" if option else str(version) 100 | 101 | 102 | def inquire_downloading(download_info, args): 103 | if args.no_cache_dir or not all(entry[-1] for entry in download_info): 104 | if not args.y: 105 | ans = input(ffdl.disclaimer_text) 106 | if ans and ans.lower() not in ("y", "yes"): 107 | print("\ndownload canceled") 108 | return True 109 | print(ffdl.donation_text) 110 | return False 111 | 112 | 113 | def install(args): 114 | # validate environmental variable related arguments 115 | env_vars = {} 116 | if args.set_env is not None: 117 | for env_spec in args.set_env: 118 | name, *var_type = env_spec.split("=", 1) 119 | if not len(var_type): 120 | env_vars[name] = "path" 121 | elif var_type[0] not in ("ffmpeg", "ffprobe", "ffplay"): 122 | raise ValueError(f"{env_vars} is not a valid set-env value.") 123 | else: 124 | env_vars[name] = var_type[0] 125 | 126 | if args.presets is not None: 127 | env_vars = ffdl.presets_to_env_vars(args.presets, env_vars) 128 | 129 | # find existing version 130 | current_version = ffdl.ffmpeg_version() 131 | 132 | def print_no_need(): 133 | print( 134 | f"Requirement already satisfied: ffmpeg=={compose_version_spec(*current_version)} in {ffdl.bin_dir()}" 135 | ) 136 | 137 | if args.version is None and current_version is not None and not args.upgrade: 138 | print_no_need() 139 | return 140 | 141 | # select the version/asset 142 | print(f"Collecting ffmpeg {args.version or ''}") 143 | version = ffdl.search( 144 | args.version, True, args.force, args.proxy, args.retries, args.timeout 145 | ) 146 | if version is None: 147 | raise RuntimeError( 148 | f"Version {args.version} of FFmpeg is either invalid or not available prebuilt." 149 | ) 150 | if current_version == version or ( 151 | args.version is None 152 | and args.upgrade 153 | and current_version is not None 154 | and current_version[0] == version[0] 155 | ): 156 | print_no_need() 157 | return 158 | 159 | ver_spec = compose_version_spec(*version) 160 | 161 | no_cache_dir = args.no_cache_dir 162 | 163 | download_info = ffdl.gather_download_info(*version, no_cache_dir=no_cache_dir) 164 | 165 | # filename, url, content_type, size, cache_file, cache_exists 166 | for entry in download_info: 167 | sz = entry[3] 168 | if sz is None: 169 | sz = "" 170 | elif isinstance(sz, int): 171 | sz = f"({round(sz / 1048576)} MB)" 172 | action = "Using cached" if not no_cache_dir and entry[-1] else "Downloading" 173 | print(f" {action} {entry[0]} {sz}") 174 | 175 | # show disclaimer if not omitted 176 | if inquire_downloading(download_info, args): 177 | # canceled by user 178 | return 179 | 180 | cache_dir = mkdtemp() if no_cache_dir else ffdl.cache_dir() 181 | try: 182 | # download the install file(s) 183 | dstpaths = ffdl.download( 184 | download_info, 185 | dst=cache_dir, 186 | no_cache_dir=no_cache_dir, 187 | proxy=args.proxy, 188 | retries=args.retries, 189 | timeout=args.timeout, 190 | progress=DownloadProgress, 191 | ) 192 | if dstpaths is None: 193 | # exit if user canceled 194 | return 195 | 196 | if current_version is not None: 197 | curr_ver_spec = compose_version_spec(*current_version) 198 | print("Attempting uninstall existing ffmpeg binaries") 199 | print(f" Found existing FFmpeg installation: {curr_ver_spec}") 200 | print(f" Uninstalling {curr_ver_spec}:") 201 | ffdl.remove() 202 | print(f" Successfully uninstalled {curr_ver_spec}") 203 | 204 | print(f"Installing collected FFmpeg binaries: {ver_spec}") 205 | 206 | ffdl.install(*dstpaths, progress=InstallProgress) 207 | 208 | finally: 209 | if args.no_cache_dir: 210 | rmtree(cache_dir, ignore_errors=True) 211 | 212 | # clear existing env vars if requested 213 | if args.reset_env: 214 | print(f"Clearing previously set environmental variables") 215 | ffdl.clr_env_vars() 216 | 217 | # set symlinks or env vars 218 | ffdl.set_env_vars(args.add_path, env_vars, args.no_simlinks) 219 | 220 | print( 221 | f"Successfully installed FFmpeg binaries: {ver_spec} in\n {ffdl.ffmpeg_path()}" 222 | ) 223 | 224 | 225 | def uninstall(args): 226 | ver = ffdl.ffmpeg_version() 227 | if ver is None: 228 | print("\nNo FFmpeg build has been downloaded.") 229 | return 230 | 231 | print(f"Found existing FFmpeg installation: {compose_version_spec(*ver)}") 232 | print(" Would remove:") 233 | print(f" {os.path.join(ffdl.get_dir(),'ffmpeg','*')}") 234 | 235 | if not args.y and input(f"Proceed (Y/n)?: ").lower() not in ("y", "yes", ""): 236 | # aborted by user 237 | return 238 | 239 | # if env_vars were set, clear them 240 | ffdl.clr_env_vars() 241 | 242 | # remove the ffmpeg directory 243 | ffdl.remove(ignore_errors=False) 244 | 245 | print(f" Successfully uninstalled FFmpeg: {compose_version_spec(*ver)}") 246 | 247 | 248 | def show(args): 249 | pass 250 | 251 | 252 | def main(prog: str = ""): 253 | # create the top-level parser 254 | parser = argparse.ArgumentParser( 255 | prog=prog, description="Download and manage FFmpeg prebuilds" 256 | ) 257 | 258 | parser.add_argument( 259 | "--proxy", 260 | type=str, 261 | nargs=1, 262 | help="Specify a proxy in the form scheme://[user:passwd@]proxy.server:port", 263 | ) 264 | parser.add_argument( 265 | "--retries", 266 | type=int, 267 | nargs=1, 268 | help="Maximum number of retries each connection should attempt (default 5 times).", 269 | default=5, 270 | ) 271 | parser.add_argument( 272 | "--timeout", 273 | type=float, 274 | nargs=1, 275 | help="Set the socket timeout (default 15 seconds).", 276 | default=15, 277 | ) 278 | parser.add_argument( 279 | "--no-cache-dir", 280 | action="store_true", 281 | help="Disable the cache.", 282 | ) 283 | parser.add_argument( 284 | "-f", 285 | "--force", 286 | action="store_true", 287 | help="Force updating the version list.", 288 | ) 289 | 290 | subparsers = parser.add_subparsers() 291 | 292 | parser_install = subparsers.add_parser("install", help="Install FFmpeg") 293 | parser_install.add_argument( 294 | "--upgrade", 295 | "-U", 296 | action="store_true", 297 | help="Upgrade existing FFmpeg to the newest available version.", 298 | ) 299 | parser_install.add_argument( 300 | "-y", action="store_true", help="Don't ask for confirmation of installation." 301 | ) 302 | parser_install.add_argument( 303 | "--add-path", 304 | help="(Windows Only) Add FFmpeg path to user PATH variable.", 305 | action="store_true", 306 | ) 307 | parser_install.add_argument( 308 | "--no-simlinks", 309 | help="(Linux/macOS) Skip creating symlinks in $HOME/.local/bin (or $HOME/bin).", 310 | action="store_true", 311 | ) 312 | # ~/Library/bin # macos 313 | parser_install.add_argument( 314 | "--set-env", 315 | help="(Windows Only) Set user environmental variables. If name only, FFmpeg path is assigned to it. To set binary file name, use name=ffmpeg, name=ffprobe, or name=ffplay", 316 | nargs="*", 317 | ) 318 | parser_install.add_argument( 319 | "--reset-env", 320 | action="store_true", 321 | help="(Windows Only) Clear previously set user environmental variables.", 322 | ) 323 | parser_install.add_argument( 324 | "--presets", 325 | help="(Windows Only) Specify target python packages to set user environmental variable for packages.", 326 | choices=list(ffdl.preset_env_vars), 327 | nargs="*", 328 | ) 329 | parser_install.add_argument( 330 | "version", 331 | nargs="?", 332 | help="FFmpeg version to install. If not specified the latest version will be installed.", 333 | # specify version and build options (version string or 'snapshot') (optional argument), 5.1.2 or 5.1.2@essential or 5.1.2@arm or snapshot 334 | ) 335 | parser_install.set_defaults(func=install) 336 | 337 | parser_list = subparsers.add_parser("list", help="List FFmpeg build versions.") 338 | parser_list.set_defaults(func=list_vers) 339 | 340 | parser_search = subparsers.add_parser( 341 | "search", help="Search matching FFmpeg build versions." 342 | ) 343 | parser_search.add_argument( 344 | "version", 345 | help="FFmpeg release version to search.", 346 | # specify version and build options (version string or 'snapshot') (optional argument), 5.1.2 or 5.1.2@essential or 5.1.2@arm or snapshot 347 | ) 348 | parser_search.set_defaults(func=search) 349 | 350 | parser_download = subparsers.add_parser("download", help="Download FFmpeg") 351 | # specify version and build options (version string or 'snapshot') (optional argument), 5.1.2 or 5.1.2@essential or 5.1.2@arm or snapshot 352 | parser_download.add_argument( 353 | "-d", "--dst", help="Download FFmpeg zip/tar file into " 354 | ) 355 | parser_download.add_argument( 356 | "-y", action="store_true", help="Don't ask for confirmation to download." 357 | ) 358 | parser_download.add_argument( 359 | "version", 360 | nargs="?", 361 | help="FFmpeg version (@option) to download.", 362 | # specify version and build options (version string or 'snapshot') (optional argument), 5.1.2 or 5.1.2@essential or 5.1.2@arm or snapshot 363 | ) 364 | parser_download.set_defaults(func=download) 365 | 366 | parser_uninstall = subparsers.add_parser("uninstall", help="Uninstall FFmpeg.") 367 | parser_uninstall.add_argument( 368 | "-y", 369 | action="store_true", 370 | help="Don't ask for confirmation of uninstall deletions.", 371 | ) 372 | parser_uninstall.set_defaults(func=uninstall) 373 | 374 | parser_cache = subparsers.add_parser("cache", help="Inspect cached FFmpeg builds.") 375 | cache_subparsers = parser_cache.add_subparsers() 376 | parser_cache_dir = cache_subparsers.add_parser( 377 | "dir", help="Show the cache directory." 378 | ) 379 | parser_cache_dir.set_defaults(func=cache_dir) 380 | parser_cache_list = cache_subparsers.add_parser( 381 | "list", help="List filenames of FFmpeg versions stored in the cache." 382 | ) 383 | parser_cache_list.set_defaults(func=cache_list) 384 | parser_cache_remove = cache_subparsers.add_parser( 385 | "remove", help="Remove one or more package from the cache." 386 | ) 387 | parser_cache_remove.add_argument( 388 | "versions", 389 | nargs="+", 390 | help="FFmpeg version (@option) to remove.", 391 | ) 392 | parser_cache_remove.set_defaults(func=cache_remove) 393 | parser_cache_purge = cache_subparsers.add_parser( 394 | "purge", help="Remove all items from the cache." 395 | ) 396 | parser_cache_purge.set_defaults(func=cache_purge) 397 | 398 | args = parser.parse_args() 399 | 400 | if "func" in args: 401 | args.func(args) 402 | else: 403 | parser.print_help() 404 | 405 | 406 | if __name__ == "__main__": 407 | main("python -m ffmpeg_downloader") 408 | -------------------------------------------------------------------------------- /src/ffmpeg_downloader/qt/wizard.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | logger.addHandler(logging.NullHandler()) 5 | 6 | from tempfile import mkdtemp 7 | from shutil import rmtree 8 | from tqdm.utils import CallbackIOWrapper 9 | from typing import Optional, Any 10 | from os import path 11 | 12 | from .. import _backend as ffdl 13 | from .qt_compat import QtCore, QtWidgets, QtGui 14 | 15 | from PyQt6.QtWidgets import QWizard 16 | 17 | QObject = QtCore.QObject 18 | QThread = QtCore.QThread 19 | pyqtSignal = QtCore.pyqtSignal 20 | pyqtSlot = QtCore.pyqtSlot 21 | Qt = QtCore.Qt 22 | 23 | QPixmap = QtGui.QPixmap 24 | 25 | QWidget = QtWidgets.QWidget 26 | # QWizard = QtWidgets.QWizard 27 | QWizardPage = QtWidgets.QWizardPage 28 | QVBoxLayout = QtWidgets.QVBoxLayout 29 | QLabel = QtWidgets.QLabel 30 | QProgressBar = QtWidgets.QProgressBar 31 | QLineEdit = QtWidgets.QLineEdit 32 | 33 | 34 | class DownloadProgress(QObject): 35 | def __init__(self, parent: QObject, diff: bool, progress) -> None: 36 | QObject.__init__(self, parent) 37 | self.diff = diff 38 | self.last = 0 39 | self.size = 0 40 | self.progress = progress 41 | 42 | def set_size(self, sz): 43 | logger.debug(f"progress: {sz} bytes total") 44 | self.size = sz 45 | 46 | def update(self, nread): 47 | if self.diff: 48 | self.last += nread 49 | else: 50 | self.last = nread 51 | logger.debug(f"progress: read {self.last}/{self.size}") 52 | self.progress.emit(self.last) 53 | 54 | def done(self): 55 | logger.debug(f"progress: done") 56 | self.progress.emit(self.size) 57 | 58 | 59 | class InstallProgress(DownloadProgress): 60 | def io_wrapper(self, fi): 61 | return CallbackIOWrapper(self.update, fi) 62 | 63 | 64 | class FFmpegInstaller(QObject): 65 | found_installed_version = pyqtSignal(str, bool) 66 | """version/done""" 67 | found_latest_version = pyqtSignal(tuple, bool) 68 | """version/done, if bool=False, wait for response via queue""" 69 | server_error = pyqtSignal(str) 70 | """str-exception message""" 71 | download_maximum = pyqtSignal(int) 72 | """download progress maximum""" 73 | download_progress = pyqtSignal(int) 74 | """download progress current""" 75 | install_maximum = pyqtSignal(int) 76 | """install progress maximum""" 77 | install_progress = pyqtSignal(int) 78 | """install progress current""" 79 | 80 | installed = pyqtSignal() 81 | """install finished""" 82 | 83 | finished = pyqtSignal() 84 | """emitted when there is no more task""" 85 | 86 | @pyqtSlot(bool, dict) 87 | def search(self, upgrade: bool, request_kws: dict[str, Any]): 88 | logger.debug("search: Begin searching for FFmpeg version") 89 | 90 | # first, check currently installed version 91 | current_version = ffdl.ffmpeg_version() 92 | already_exists = current_version is not None and not upgrade 93 | 94 | self.found_installed_version.emit( 95 | str(current_version[0]) if current_version else "", already_exists 96 | ) 97 | 98 | if already_exists: 99 | logger.info("search: FFmpeg already installed") 100 | self.finished.emit() 101 | return 102 | 103 | try: 104 | version = ffdl.search( 105 | version_spec=None, auto_select=True, force=True, **request_kws 106 | ) 107 | if version is None: 108 | raise RuntimeError("Could not find a suitable FFMpeg version.") 109 | except Exception as e: 110 | self.server_error.emit(str(e)) 111 | self.finished.emit() 112 | return 113 | 114 | # check for a need to update 115 | already_exists = current_version is not None and ( 116 | current_version[0] == version[0] 117 | ) 118 | 119 | self.found_latest_version.emit(version, already_exists) 120 | if already_exists: 121 | self.finished.emit() 122 | 123 | @pyqtSlot(tuple, bool, dict) 124 | def install(self, version: tuple, exists: bool, request_kws: dict): 125 | # import debugpy 126 | # debugpy.debug_this_thread() 127 | logger.debug(f"install: begin installing v{version[0]}") 128 | 129 | download_info = ffdl.gather_download_info(*version, no_cache_dir=True) 130 | 131 | dl_mon = DownloadProgress(self, False, self.download_progress) 132 | in_mon = InstallProgress(self, True, self.install_progress) 133 | 134 | def downloadProgress(sz): 135 | self.download_maximum.emit(sz) 136 | dl_mon.set_size(sz) 137 | return dl_mon 138 | 139 | def installProgress(sz): 140 | self.install_maximum.emit(sz) 141 | in_mon.set_size(sz) 142 | return in_mon 143 | 144 | cache_dir = mkdtemp() 145 | try: 146 | # download 147 | dstpaths = ffdl.download( 148 | download_info, 149 | dst=cache_dir, 150 | progress=downloadProgress, 151 | no_cache_dir=True, 152 | **request_kws, 153 | ) 154 | dl_mon.done() 155 | 156 | if exists: 157 | ffdl.remove() 158 | 159 | # install 160 | ffdl.install(*dstpaths, progress=installProgress) 161 | 162 | # all done 163 | in_mon.done() 164 | self.installed.emit() 165 | 166 | except Exception as e: 167 | self.server_error.emit(str(e)) 168 | 169 | finally: 170 | rmtree(cache_dir, ignore_errors=True) 171 | self.finished.emit() 172 | 173 | 174 | class InstallFFmpegWizard(QWizard): 175 | req_search = pyqtSignal(bool, dict) 176 | req_install = pyqtSignal(tuple, bool, dict) 177 | 178 | default_labels = { 179 | "search_current": "Searching for a local copy of the FFmpeg, a multimedia processing library...", 180 | "press_next": f"""Press Next to download the latest FFmpeg release build from 181 | {ffdl.home_url}. 182 | 183 | Disclaimer: Proceeding to download the file is done at your own 184 | discretion and risk and with agreement that you will be solely 185 | responsible for any damage to your computer system or loss of data that results from 186 | such activities.""", 187 | "current_ver": "Existing FFmpeg Version: v{}", 188 | "not_found": "Existing FFmpeg: Not Found", 189 | "exists": "FFmpeg already installed.", 190 | "search_latest": f'Searching for the latest FFmpeg version at {ffdl.home_url}...', 191 | "latest_ver": "Latest FFmpeg Version: v{}", 192 | "latest_installed": "The latest version already installed.", 193 | "dl_progress": "Download Progress", 194 | "ins_progress": "Install Progress", 195 | "ins_folder": "Installation Folder:", 196 | "ins_success": "Installation success", 197 | } 198 | 199 | def __init__( 200 | self, 201 | upgrade: bool = False, 202 | wizard_style: Optional[QWizard.WizardStyle] = None, 203 | wizard_pixmaps: Optional[dict[QWizard.WizardPixmap, QPixmap]] = None, 204 | window_title: Optional[str] = None, 205 | window_size: Optional[tuple[int, int]] = None, 206 | custom_labels: Optional[dict[str, str]] = None, 207 | parent: Optional[QWidget] = None, 208 | flags: Optional[Qt.WindowType] = None, 209 | proxy: Optional[str] = None, 210 | retries: Optional[int] = None, 211 | timeout: Optional[float | tuple[float, float]] = None, 212 | ): 213 | QWizard.__init__(self, parent) 214 | 215 | self.upgrade: bool = upgrade 216 | self.request_kws: dict = { 217 | "proxy": proxy, 218 | "retries": retries, 219 | "timeout": timeout, 220 | } 221 | self.old_ver_exists: bool = False 222 | self.version: tuple = None # set by Page1, used by Page2 223 | self.install_finished: bool = False 224 | 225 | self.setWindowTitle(window_title or "FFmpeg Download & Install Wizard") 226 | if window_size is not None: 227 | self.resize(*window_size) 228 | 229 | if flags is not None: 230 | self.setWindowFlags(flags) 231 | 232 | self.setWizardStyle(wizard_style or QWizard.WizardStyle.ModernStyle) 233 | 234 | if wizard_pixmaps is None: 235 | wizard_pixmaps = {} 236 | if QWizard.WizardPixmap.WatermarkPixmap not in wizard_pixmaps: 237 | svgfile = path.join(path.dirname(__file__), "assets", "FFmpeg_icon.svg") 238 | wizard_pixmaps[QWizard.WizardPixmap.WatermarkPixmap] = QPixmap(svgfile) 239 | 240 | for which, pixmap in wizard_pixmaps.items(): 241 | self.setPixmap(which, pixmap) 242 | 243 | self.setOptions( 244 | QWizard.WizardOption.NoBackButtonOnStartPage 245 | | QWizard.WizardOption.NoBackButtonOnLastPage 246 | ) 247 | 248 | self.installer = FFmpegInstaller() 249 | self.installer_thread = QThread() 250 | self.installer.moveToThread(self.installer_thread) 251 | self.installer_thread.started.connect(self._thread_started) 252 | self.req_search.connect(self.installer.search) 253 | self.req_install.connect(self.installer.install) 254 | self.installer.finished.connect(self.installer_thread.quit) 255 | self.installer.finished.connect(self.installer.deleteLater) 256 | self.installer.finished.connect(self.installer_finished) 257 | self.installer_thread.finished.connect(self.installer_thread.deleteLater) 258 | self.installer_thread.finished.connect(self._thread_finished) 259 | self.installer.found_latest_version.connect(self._found_latest_version) 260 | self.installer.found_installed_version.connect(self._found_installed_version) 261 | 262 | labels = {**self.default_labels} 263 | if custom_labels: 264 | labels.update(custom_labels) 265 | 266 | self.page1 = self.addPage(Page1(self, labels)) 267 | self.page2 = self.addPage(Page2(self, labels)) 268 | 269 | self.installer_thread.start() 270 | 271 | @pyqtSlot() 272 | def _thread_started(self): 273 | logger.debug("thread started") 274 | 275 | def _thread_finished(self): 276 | logger.debug("thread finished") 277 | 278 | @pyqtSlot() 279 | def installer_finished(self): 280 | logger.debug("installer finished") 281 | 282 | def emit_search_request(self): 283 | self.req_search.emit(self.upgrade, self.request_kws) 284 | 285 | @pyqtSlot(str, bool) 286 | def _found_installed_version(self, ver: str, done: bool): 287 | self.old_ver_exists = bool(ver) 288 | if done: 289 | self.install_finished = True 290 | 291 | @pyqtSlot(tuple, bool) 292 | def _found_latest_version(self, ver: tuple, done: bool): 293 | self.version = ver 294 | if done: 295 | self.install_finished = True 296 | 297 | def emit_install_request(self): 298 | self.req_install.emit(self.version, self.old_ver_exists, self.request_kws) 299 | 300 | def nextId(self) -> int: 301 | return -1 if self.install_finished else super().nextId() 302 | 303 | 304 | class Page1(QWizardPage): 305 | """Confirm""" 306 | 307 | def __init__(self, parent: InstallFFmpegWizard, labels: dict[str, str]): 308 | super().__init__(parent) 309 | 310 | # TODO - Add update button 311 | 312 | parent.installer.found_installed_version.connect(self._found_installed_version) 313 | parent.installer.found_latest_version.connect(self._found_latest_version) 314 | self.complete = False 315 | 316 | layout = QVBoxLayout() 317 | self.setLayout(layout) 318 | 319 | self.curr_ver_label = label = QLabel(self) 320 | label.setText(labels["search_current"]) 321 | label.setWordWrap(True) 322 | layout.addWidget(label) 323 | 324 | self.latest_ver_label = QLabel(self) 325 | self.latest_ver_label.setTextFormat(Qt.TextFormat.RichText) 326 | self.latest_ver_label.setTextInteractionFlags( 327 | Qt.TextInteractionFlag.TextBrowserInteraction 328 | ) 329 | self.latest_ver_label.setOpenExternalLinks(True) 330 | layout.addWidget(self.latest_ver_label) 331 | 332 | self.disclaimer_label = QLabel(self) 333 | self.disclaimer_label.setTextFormat(Qt.TextFormat.RichText) 334 | self.disclaimer_label.setTextInteractionFlags( 335 | Qt.TextInteractionFlag.TextBrowserInteraction 336 | ) 337 | self.disclaimer_label.setOpenExternalLinks(True) 338 | self.disclaimer_label.setText(labels["press_next"]) 339 | self.disclaimer_label.setVisible(False) 340 | layout.addWidget(self.disclaimer_label) 341 | 342 | self.labels = { 343 | k: v 344 | for k, v in labels.items() 345 | if k 346 | in ( 347 | "current_ver", 348 | "not_found", 349 | "exists", 350 | "search_latest", 351 | "latest_ver", 352 | "latest_installed", 353 | ) 354 | } 355 | 356 | def isComplete(self) -> bool: 357 | return self.complete 358 | 359 | @pyqtSlot(str, bool) 360 | def _found_installed_version(self, ver, done): 361 | logger.debug("executing _found_installed_version") 362 | self.complete = done 363 | self.curr_ver_label.setText( 364 | self.labels["current_ver"].format(ver) if ver else self.labels["not_found"] 365 | ) 366 | if done: 367 | self.latest_ver_label.setText(self.labels["exists"]) 368 | self.setFinalPage(True) 369 | self.completeChanged.emit() 370 | else: 371 | self.latest_ver_label.setText(self.labels["search_latest"]) 372 | 373 | @pyqtSlot(tuple, bool) 374 | def _found_latest_version(self, ver, done): 375 | self.complete = True 376 | self.latest_ver_label.setText(self.labels["latest_ver"].format(ver[0])) 377 | if done: 378 | self.disclaimer_label.setText(self.labels["latest_installed"]) 379 | self.setFinalPage(True) 380 | 381 | self.disclaimer_label.setVisible(True) 382 | self.completeChanged.emit() 383 | 384 | @pyqtSlot(str) 385 | def _server_err(self, err: str): 386 | logger.critical(err) 387 | # TODO - display on the wizard ui 388 | 389 | def initializePage(self): 390 | wiz: InstallFFmpegWizard = self.wizard() 391 | wiz.emit_search_request() 392 | 393 | 394 | class Page2(QWizardPage): 395 | def __init__(self, parent: InstallFFmpegWizard, labels: dict[str, str]): 396 | super().__init__(parent) 397 | 398 | self._is_installed = False 399 | 400 | parent.installer.download_maximum.connect(self._bar1_set_maximum) 401 | parent.installer.install_maximum.connect(self._bar2_set_maximum) 402 | parent.installer.download_progress.connect(self._bar1_value_changed) 403 | parent.installer.install_progress.connect(self._bar2_value_changed) 404 | parent.installer.installed.connect(self._installed) 405 | 406 | label1 = QLabel(self) 407 | label1.setText(labels["dl_progress"]) 408 | self.bar1 = QProgressBar(self) 409 | label2 = QLabel(self) 410 | label2.setText(labels["ins_progress"]) 411 | self.bar2 = QProgressBar(self) 412 | label3 = QLabel(self) 413 | label3.setText(labels["ins_folder"]) 414 | label4 = QLineEdit(self) 415 | label4.setText(ffdl.ffmpeg_path()) 416 | label4.setReadOnly(True) 417 | label5 = QLabel(self) 418 | label5.setText(labels["ins_success"]) 419 | label5.setVisible(False) 420 | self.complete_label = label5 421 | layout = QVBoxLayout() 422 | layout.addWidget(label1) 423 | layout.addWidget(self.bar1) 424 | layout.addWidget(label2) 425 | layout.addWidget(self.bar2) 426 | layout.addWidget(label3) 427 | layout.addWidget(label4) 428 | layout.addWidget(label5) 429 | self.setLayout(layout) 430 | 431 | def initializePage(self): 432 | self.wizard().emit_install_request() 433 | 434 | @pyqtSlot(int) 435 | def _bar1_set_maximum(self, max: int): 436 | self.bar1.setMaximum(max) 437 | 438 | @pyqtSlot(int) 439 | def _bar2_set_maximum(self, max: int): 440 | self.bar2.setMaximum(max) 441 | 442 | @pyqtSlot(int) 443 | def _bar1_value_changed(self, value: int): 444 | self.bar1.setValue(value) 445 | 446 | @pyqtSlot(int) 447 | def _bar2_value_changed(self, value: int): 448 | self.bar2.setValue(value) 449 | 450 | @pyqtSlot() 451 | def _installed(self): 452 | self._is_installed = True 453 | self.completeChanged.emit() 454 | self.complete_label.setVisible(True) 455 | 456 | def isComplete(self) -> bool: 457 | return self._is_installed 458 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | --------------------------------------------------------------------------------