├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------