├── .gitignore ├── .readthedocs.yaml ├── AUTHORS ├── LICENSE ├── Makefile ├── README.md ├── appveyor.yml ├── docs ├── conf.py ├── index.rst ├── introduction.rst ├── pymediainfo.rst └── requirements.txt ├── pylintrc ├── pyproject.toml ├── scripts ├── demo.py ├── download_library.py └── tag_pure_wheels.py ├── src └── pymediainfo │ ├── __init__.py │ └── py.typed ├── tests ├── data │ ├── aac_he_v2.aac │ ├── accentué.txt │ ├── empty.gif │ ├── invalid.xml │ ├── issue100.xml │ ├── issue55.flv │ ├── mp3.mp3 │ ├── mp4-with-audio.mp4 │ ├── mpeg4.mp4 │ ├── other_track.xml │ ├── sample.mkv │ ├── sample.mp4 │ ├── sample.xml │ ├── sample_with_cover.mp3 │ └── vbr_requires_parsespeed_1.mp4 └── test_pymediainfo.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.*project 2 | *.komodo* 3 | *.pyc 4 | *.dll 5 | *.egg-info 6 | build 7 | dist 8 | docs/_build 9 | .pdm* 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | # Set the version of Python and other tools you might need 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.12" 11 | jobs: 12 | pre_install: 13 | # We want setuptools_scm to report the correct version, see 14 | # https://github.com/readthedocs/readthedocs.org/issues/2144#issuecomment-1695425010 15 | # and https://docs.readthedocs.io/en/latest/build-customization.html#avoid-having-a-dirty-git-index 16 | - git update-index --assume-unchanged docs/conf.py 17 | 18 | # Build documentation in the docs/ directory with Sphinx 19 | sphinx: 20 | configuration: docs/conf.py 21 | 22 | # Install the required dependencies 23 | python: 24 | install: 25 | - requirements: docs/requirements.txt 26 | # Install pymediainfo itself to make autodoc work 27 | - path: . 28 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Patrick Altman (author) 2 | cjlucas https://github.com/cjlucas 3 | Louis Sautier (maintainer since 2016) 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2014, Patrick Altman 4 | Copyright (c) 2016, Louis Sautier 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | 24 | http://www.opensource.org/licenses/mit-license.php 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: 3 | tox -p 4 | 5 | .PHONY: qa 6 | qa: 7 | tox -p -e "docs,black,flake8,isort,mypy,pylint" 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pymediainfo 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/pymediainfo.svg)](https://pypi.org/project/pymediainfo) 4 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/pymediainfo.svg)](https://pypi.org/project/pymediainfo) 5 | [![Repology info](https://repology.org/badge/tiny-repos/python%3Apymediainfo.svg)](https://repology.org/project/python%3Apymediainfo/versions) 6 | [![GitHub](https://img.shields.io/github/stars/sbraz/pymediainfo)](https://github.com/sbraz/pymediainfo) 7 | [![Build status](https://ci.appveyor.com/api/projects/status/g15a2daem1oub57n/branch/master?svg=true)](https://ci.appveyor.com/project/sbraz/pymediainfo) 8 | 9 | pymediainfo is a wrapper for [the MediaInfo library](https://mediaarea.net/en/MediaInfo). It makes it easy to 10 | extract detailed information from multimedia files. 11 | 12 | ## Compatibility 13 | 14 | pymediainfo is compatible with the following: 15 | 16 | * **Platforms**: **Linux**, **macOS** and **Windows**. 17 | * **Python Versions**: Tested with Python **3.9** (the minimum required version) to **3.13**, as well as **PyPy3**. 18 | 19 | ## Installation 20 | 21 | Please note that, without [the MediaInfo library](https://mediaarea.net/en/MediaInfo), pymediainfo 22 | **cannot parse media files**. This severely limits its functionality, allowing it to process 23 | only pre-generated XML output from MediaInfo. 24 | 25 | ### Linux distribution Packages 26 | 27 | Packages are available for [most major Linux distributions](https://repology.org/project/python%3Apymediainfo/versions). 28 | They often depend on the MediaInfo library and are the preferred way to 29 | install pymediainfo on Linux, as they allow for independent updates to pymediainfo and the MediaInfo library itself. 30 | 31 | ### PyPI on Linux, macOS and Windows 32 | 33 | If pymediainfo is not available for your Linux distribution, or if you're running macOS or Windows, 34 | you can install it from PyPI: 35 | ``` 36 | python -m pip install pymediainfo 37 | ``` 38 | 39 | **Wheels** containing a bundled version of the MediaInfo library are available for: 40 | 41 | * Linux x86-64 and ARM64. 42 | * macOS x86-64 and ARM64. 43 | * Windows x86-64 and x86. 44 | 45 | If you do not want to use the wheels (for instance if you want to use the system-wide 46 | MediaInfo library instead of the bundled one): 47 | ``` 48 | python -m pip install pymediainfo --no-binary pymediainfo 49 | ``` 50 | 51 | ## Usage 52 | 53 | Here are a few examples demonstrating how to use pymediainfo. 54 | ### Getting information from an image 55 | The `MediaInfo` class provides a `parse()` method which takes paths as input and returns `MediaInfo` objects. 56 | #### Example snippet 57 | 58 | ```py 59 | from pymediainfo import MediaInfo 60 | 61 | media_info = MediaInfo.parse("/home/user/image.jpg") 62 | # Tracks can be accessed using the 'tracks' attribute or through shorthands 63 | # such as 'image_tracks', 'audio_tracks', 'video_tracks', etc. 64 | general_track = media_info.general_tracks[0] 65 | image_track = media_info.image_tracks[0] 66 | print( 67 | f"{image_track.format} of {image_track.width}×{image_track.height} pixels" 68 | f" and {general_track.file_size} bytes." 69 | ) 70 | ``` 71 | 72 | #### Example output 73 | 74 | ```text 75 | JPEG of 828×828 pixels and 19098 bytes. 76 | ``` 77 | 78 | ### Getting information from a video 79 | 80 | In this example, we take advantage of the `to_data()` method, which returns a `dict` containing all 81 | attributes from a `MediaInfo` or `Track` object. This makes it 82 | easier to inspect tracks even when their attributes are unknown. 83 | 84 | #### Example snippet 85 | 86 | ```py 87 | from pprint import pprint 88 | from pymediainfo import MediaInfo 89 | 90 | media_info = MediaInfo.parse("my_video_file.mp4") 91 | for track in media_info.tracks: 92 | if track.track_type == "Video": 93 | print(f"Bit rate: {track.bit_rate}, Frame rate: {track.frame_rate}, Format: {track.format}") 94 | print("Duration (raw value):", track.duration) 95 | print("Duration (other values:") 96 | pprint(track.other_duration) 97 | elif track.track_type == "Audio": 98 | print("Track data:") 99 | pprint(track.to_data()) 100 | ``` 101 | 102 | #### Example output 103 | 104 | ```text 105 | Bit rate: 3117597, Frame rate: 23.976, Format: AVC 106 | Duration (raw value): 958 107 | Duration (other values): 108 | ['958 ms', 109 | '958 ms', 110 | '958 ms', 111 | '00:00:00.958', 112 | '00:00:00;23', 113 | '00:00:00.958 (00:00:00;23)'] 114 | Track data: 115 | {'bit_rate': 236392, 116 | 'bit_rate_mode': 'VBR', 117 | 'channel_layout': 'L R', 118 | 'channel_positions': 'Front: L R', 119 | 'channel_s': 2, 120 | 'codec_id': 'mp4a-40-2', 121 | 'commercial_name': 'AAC', 122 | 'compression_mode': 'Lossy', 123 | … 124 | } 125 | ``` 126 | 127 | ### Accessing Track attributes 128 | 129 | Since the attributes from a `Track` are dynamically created during parsing, there isn't a firm definition 130 | of what will be available at runtime. 131 | 132 | In order to make consuming objects easier, the `__getattribute__` method from `Track` objects 133 | has been overridden to return `None` when a non-existent attribute is accessed, instead of raising `AttributeError`. 134 | 135 | #### Example snippet 136 | ```py 137 | from pymediainfo import MediaInfo 138 | 139 | media_info = MediaInfo.parse("my_video_file.mp4") 140 | for track in media_info.tracks: 141 | if track.bit_rate is None: 142 | print(f"{track.track_type} tracks do not have a bit rate associated with them") 143 | else: 144 | print(f"Track {track.track_id} of type {track.track_type} has a bit rate of {track.bit_rate} b/s") 145 | ``` 146 | 147 | #### Example output 148 | 149 | ```text 150 | General tracks do not have a bit rate associated with them 151 | Track 1 of type Video has a bit rate of 4398075 b/s 152 | Track 2 of type Audio has a bit rate of 131413 b/s 153 | Menu tracks do not have a bit rate associated with them 154 | ``` 155 | 156 | 157 | ### Parsing pre-generated MediaInfo XML output 158 | pymediainfo relies on MediaInfo's `OLDXML` output to create `MediaInfo` objects. 159 | 160 | It is possible to create a `MediaInfo` object from an existing XML string. For 161 | instance if someone sent you the output of `mediainfo --output=OLDXML`, you can 162 | call the `MediaInfo` constructor directly. 163 | 164 | #### Example snippet 165 | ```py 166 | from pymediainfo import MediaInfo 167 | 168 | raw_xml_string = """ 169 | 170 | 171 | 172 | binary_file 173 | 1.00 Byte 174 | 175 | 176 | """ 177 | media_info = MediaInfo(raw_xml_string) 178 | print(f"File name is: {media_info.general_tracks[0].complete_name}") 179 | ``` 180 | 181 | #### Example output 182 | ```text 183 | File name is: binary_file 184 | ``` 185 | 186 | ### Text output (à la `mediainfo`) 187 | 188 | If you want a text report, similar to what `mediainfo my_video_file.mp4` outputs, 189 | use the `output="text"` argument with the `parse()` method. In this case, it 190 | will return a string, not a `MediaInfo` object. 191 | 192 | #### Example snippet 193 | ```py 194 | from pymediainfo import MediaInfo 195 | 196 | # To mirror a simple call to "mediainfo" without the "--Full" or "-f" option, we 197 | # set "full=False". Leaving it at the default of "full=True" would result in 198 | # more verbose output. 199 | print(MediaInfo.parse("my_video_file.mp4", output="text", full=False)) 200 | ``` 201 | 202 | #### Example output 203 | ```text 204 | General 205 | Complete name : my_video_file.mp4 206 | Format : MPEG-4 207 | Format profile : Base Media 208 | […] 209 | ``` 210 | 211 | ## Documentation 212 | 213 | For more detailed information, please refer to the reference documentation 214 | available at . 215 | 216 | ## Issues and Questions 217 | For feature requests and bug reports, please use the GitHub issue tracker at 218 | . 219 | 220 | If you have any questions, feel free to ask in the discussions at 221 | . 222 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | LINUX_IMAGE: &linux_image Ubuntu2204 3 | MACOS_IMAGE: &macos_image macos-monterey 4 | WINDOWS_IMAGE: &windows_image Visual Studio 2022 5 | # Some of the QA fails on 3.12, see https://github.com/psf/black/issues/4544 6 | # and https://github.com/appveyor/ci/issues/3927#issuecomment-2649582770 7 | QA_PYTHON_VERSION: 3.12 8 | # Python 3.13 is not available everywhere yet, see https://github.com/appveyor/ci/issues/3927 9 | # Because of this, we can only deploy on 3.12 10 | DEPLOY_TOXENV: py312 11 | PYPY_URL: https://downloads.python.org/pypy/pypy3.11-v7.3.18-linux64.tar.bz2 12 | # Work around https://github.com/tox-dev/tox/issues/1550 13 | PYTHONIOENCODING: utf-8 14 | TWINE_USERNAME: "__token__" 15 | TWINE_PASSWORD: 16 | secure: Jp2QpmAii1mmAXmotdXmPx5q679oMcRolziuu9m2pawkvOnRJtWMsI4uWiTiSbiw+HMbyyWwVpy+FiaPsHZxtM863PNNJidW1WDam4kn8EM+rznjgZfO9NSCcwZJU5jcTYCwuXo3+FnVNK5rvQ8QJ+Zu6WzH1Ysb+uJSz8e6xt7d7hoZbb9VH5bJC7tYrw+bH+TfA9juVpIYfCavozLLTDLqTcvPfJ+LXMPbiZO+oOztNsLRsviH2QAPXaLspXvCr6qUVH3A84KCdfSXCOZG0g/eYUZ6ilMLESe7DrYZrRc= 17 | matrix: 18 | - TOXENV: docs,black,flake8,isort,mypy,pylint 19 | APPVEYOR_BUILD_WORKER_IMAGE: *linux_image 20 | # The BUILD_TYPE variable allows us to separate normal Linux builds from 21 | # the QA one 22 | BUILD_TYPE: qa 23 | - TOXENV: pypy3 24 | APPVEYOR_BUILD_WORKER_IMAGE: *linux_image 25 | BUILD_TYPE: linux 26 | - TOXENV: py39 27 | APPVEYOR_BUILD_WORKER_IMAGE: *linux_image 28 | BUILD_TYPE: linux 29 | - TOXENV: py310 30 | APPVEYOR_BUILD_WORKER_IMAGE: *linux_image 31 | BUILD_TYPE: linux 32 | - TOXENV: py311 33 | APPVEYOR_BUILD_WORKER_IMAGE: *linux_image 34 | BUILD_TYPE: linux 35 | - TOXENV: py312 36 | APPVEYOR_BUILD_WORKER_IMAGE: *linux_image 37 | BUILD_TYPE: linux 38 | - TOXENV: py313 39 | APPVEYOR_BUILD_WORKER_IMAGE: *linux_image 40 | BUILD_TYPE: linux 41 | - TOXENV: py39 42 | APPVEYOR_BUILD_WORKER_IMAGE: *macos_image 43 | - TOXENV: py310 44 | APPVEYOR_BUILD_WORKER_IMAGE: *macos_image 45 | - TOXENV: py311 46 | APPVEYOR_BUILD_WORKER_IMAGE: *macos_image 47 | - TOXENV: py312 48 | APPVEYOR_BUILD_WORKER_IMAGE: *macos_image 49 | - TOXENV: py39 50 | PYTHON: "C:/Python39" 51 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 52 | - TOXENV: py39 53 | PYTHON: "C:/Python39-x64" 54 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 55 | - TOXENV: py310 56 | PYTHON: "C:/Python310" 57 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 58 | - TOXENV: py310 59 | PYTHON: "C:/Python310-x64" 60 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 61 | - TOXENV: py311 62 | PYTHON: "C:/Python311" 63 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 64 | - TOXENV: py311 65 | PYTHON: "C:/Python311-x64" 66 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 67 | - TOXENV: py312 68 | PYTHON: "C:/Python312" 69 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 70 | - TOXENV: py312 71 | PYTHON: "C:/Python312-x64" 72 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 73 | - TOXENV: py313 74 | PYTHON: "C:/Python313" 75 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 76 | - TOXENV: py313 77 | PYTHON: "C:/Python313-x64" 78 | APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 79 | for: 80 | - 81 | matrix: 82 | only: 83 | - APPVEYOR_BUILD_WORKER_IMAGE: *windows_image 84 | install: 85 | - "SET PATH=%PYTHON%;%PYTHON%/Scripts;%PATH%" 86 | - "python --version" 87 | - "pip install pdm tox" 88 | - "pdm install --no-self" 89 | build_script: 90 | - ps: | 91 | if ($env:PYTHON.EndsWith('-x64')) { 92 | $pdm_arch = 'x86_64' 93 | $mediainfo_arch = 'x64' 94 | $expected_hash = '86e915c2eb14a78b90806fdb51738e745c3f657788078b3398eb857e7ffa2a9cdd19d69f448d9f07842bb45b53f2c77ac9ef516df1adc2a967a1f1df05c621e7' 95 | } else { 96 | $pdm_arch = 'i386' 97 | $mediainfo_arch = 'i386' 98 | $expected_hash = '704d3e36cf7a59ca447aed415fab8c39e8037ba527b96fcc4d5dcc0faba04be0f51d7938d61ae935d78a7b25f19bfb6fce1b406621650fb3bcc386d56c805752' 99 | } 100 | # cURL is required for test_parse_url 101 | $MEDIAINFO_VERSION = '24.12' 102 | $file = "MediaInfo_CLI_${MEDIAINFO_VERSION}_Windows_${mediainfo_arch}.zip" 103 | Start-FileDownload "https://mediaarea.net/download/binary/mediainfo/${MEDIAINFO_VERSION}/${file}" 104 | $hash = (Get-FileHash "${file}" -Algorithm SHA512).Hash 105 | if ($hash -ne $expected_hash) { 106 | Write-Error "Hash mismatch for ${file}: expected ${expected_hash}, got ${hash}" 107 | exit 1 108 | } 109 | unzip -o "${file}" LIBCURL.DLL 110 | pdm run "build_win32_${pdm_arch}" 111 | # Install the wheel we created 112 | Get-ChildItem dist/*.whl | ForEach-Object { pip install $_.FullName } 113 | test_script: 114 | - "tox" 115 | deploy_script: 116 | - ps: | 117 | If (($env:APPVEYOR_REPO_TAG -eq "true") -and ($env:TOXENV -eq $env:DEPLOY_TOXENV)) { 118 | pip install twine 119 | twine upload --skip-existing dist/*.whl 120 | } 121 | - 122 | matrix: 123 | only: 124 | - APPVEYOR_BUILD_WORKER_IMAGE: *macos_image 125 | install: | 126 | set -eo pipefail 127 | PYTHON_VERSION="$(sed -E 's/^py(3)(.*)$/\1.\2/' <<< "$TOXENV")" 128 | source "${HOME}/venv${PYTHON_VERSION}/bin/activate" 129 | python --version 130 | pip install pdm tox 131 | pdm install --no-self 132 | build_script: | 133 | set -eo pipefail 134 | pdm run build_darwin 135 | # Install the wheel we created 136 | pip install dist/*.whl 137 | test_script: 138 | - "tox" 139 | deploy_script: | 140 | set -eo pipefail 141 | if [[ $APPVEYOR_REPO_TAG == "true" && $TOXENV == $DEPLOY_TOXENV ]]; then 142 | pip install twine 143 | twine upload --skip-existing dist/*.whl 144 | fi 145 | - 146 | matrix: 147 | only: 148 | - BUILD_TYPE: linux 149 | install: | 150 | set -eo pipefail 151 | if [[ $TOXENV == pypy3 ]]; then 152 | pushd /tmp 153 | curl -sS "$PYPY_URL" | tar xj 154 | PATH="$(pwd)/$(basename "$PYPY_URL" | sed -E 's/\.tar\.[^.]+$//')/bin/:$PATH" 155 | python -m ensurepip 156 | popd 157 | else 158 | PYTHON_VERSION="$(sed -E 's/^py(3)(.*)$/\1.\2/' <<< "$TOXENV")" 159 | source "${HOME}/venv${PYTHON_VERSION}/bin/activate" 160 | fi 161 | python --version 162 | # "python -m pip" will work with the unpacked PyPy too, "pip" won't 163 | python -m pip install pdm tox 164 | pdm install --no-self 165 | build_script: | 166 | set -eo pipefail 167 | # Build the source distribution (sdist) first, this way we make sure it 168 | # won't contain libmediainfo.so.0 which we download later 169 | pdm build -v --no-wheel 170 | # Each pdm build clears the "dist" folder and we need to keep all the 171 | # created files to upload them at a later stage 172 | mkdir dist_files 173 | mv -v dist/*.gz dist_files/ 174 | # wheel for arm64 175 | pdm run build_linux_arm64 176 | mv -v dist/*.whl dist_files/ 177 | # wheel for x86_64 178 | pdm run build_linux_x86_64 179 | # Install the wheel we created 180 | pip install dist/*.whl 181 | # Move back the arm64 and source distributions to "dist" 182 | mv -v dist_files/* dist/ 183 | test_script: | 184 | # We want to see the progression of the tests so we can't run 185 | # tox environments in parallel 186 | tox 187 | deploy_script: | 188 | set -eo pipefail 189 | if [[ $APPVEYOR_REPO_TAG == "true" && $TOXENV == $DEPLOY_TOXENV ]]; then 190 | pip install twine 191 | twine upload --skip-existing dist/*.gz dist/*.whl 192 | fi 193 | - 194 | matrix: 195 | only: 196 | - BUILD_TYPE: qa 197 | install: | 198 | set -eo pipefail 199 | source "${HOME}/venv${QA_PYTHON_VERSION}/bin/activate" 200 | python --version 201 | pip install tox 202 | build: off 203 | test_script: | 204 | TOX_PARALLEL_NO_SPINNER=1 tox -p 205 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # pymediainfo documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Feb 9 10:51:37 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import os 17 | 18 | import setuptools_scm 19 | 20 | try: 21 | from importlib import metadata 22 | except ImportError: 23 | import importlib_metadata as metadata # type: ignore 24 | 25 | # If extensions (or modules to document with autodoc) are in another directory, 26 | # add these directories to sys.path here. If the directory is relative to the 27 | # documentation root, use os.path.abspath to make it absolute, like shown here. 28 | #sys.path.insert(0, os.path.abspath('.')) 29 | 30 | # -- General configuration ------------------------------------------------ 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | #needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | 'myst_parser', 40 | 'sphinx.ext.autodoc', 41 | ] 42 | 43 | # Type hints aren't very readable in the doc at the moment 44 | autodoc_typehints = "none" 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The encoding of source files. 50 | #source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = 'pymediainfo' 57 | copyright = 'Patrick Altman, Louis Sautier' 58 | author = 'Patrick Altman, Louis Sautier' 59 | 60 | # fallback_root must be specified for this to work with PyPI tarballs 61 | version = setuptools_scm.get_version(root="..", fallback_root="..", relative_to=__file__) 62 | 63 | # The full version, including alpha/beta/rc tags. 64 | release = version 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = "en" 72 | 73 | # There are two options for replacing |today|: either, you set today to some 74 | # non-false value, then it is used: 75 | #today = '' 76 | # Else, today_fmt is used as the format for a strftime call. 77 | #today_fmt = '%B %d, %Y' 78 | 79 | # List of patterns, relative to source directory, that match files and 80 | # directories to ignore when looking for source files. 81 | exclude_patterns = ['_build'] 82 | 83 | # The reST default role (used for this markup: `text`) to use for all 84 | # documents. 85 | #default_role = None 86 | 87 | # If true, '()' will be appended to :func: etc. cross-reference text. 88 | #add_function_parentheses = True 89 | 90 | # If true, the current module name will be prepended to all description 91 | # unit titles (such as .. function::). 92 | #add_module_names = True 93 | 94 | # If true, sectionauthor and moduleauthor directives will be shown in the 95 | # output. They are ignored by default. 96 | #show_authors = False 97 | 98 | # The name of the Pygments (syntax highlighting) style to use. 99 | pygments_style = 'sphinx' 100 | 101 | # A list of ignored prefixes for module index sorting. 102 | #modindex_common_prefix = [] 103 | 104 | # If true, keep warnings as "system message" paragraphs in the built documents. 105 | #keep_warnings = False 106 | 107 | # If true, `todo` and `todoList` produce output, else they produce nothing. 108 | todo_include_todos = False 109 | 110 | 111 | # -- Options for HTML output ---------------------------------------------- 112 | 113 | # The theme to use for HTML and HTML Help pages. See the documentation for 114 | # a list of builtin themes. 115 | html_theme = 'alabaster' 116 | 117 | # Theme options are theme-specific and customize the look and feel of a theme 118 | # further. For a list of options available for each theme, see the 119 | # documentation. 120 | html_theme_options = { 121 | "page_width": "auto", 122 | "fixed_sidebar": True, 123 | "github_user": "sbraz", 124 | "github_repo": "pymediainfo", 125 | "github_type": "star", 126 | } 127 | 128 | # Add any paths that contain custom themes here, relative to this directory. 129 | #html_theme_path = [] 130 | 131 | # The name for this set of Sphinx documents. If None, it defaults to 132 | # " v documentation". 133 | #html_title = None 134 | 135 | # A shorter title for the navigation bar. Default is the same as html_title. 136 | #html_short_title = None 137 | 138 | # The name of an image file (relative to this directory) to place at the top 139 | # of the sidebar. 140 | #html_logo = None 141 | 142 | # The name of an image file (within the static path) to use as favicon of the 143 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 144 | # pixels large. 145 | #html_favicon = None 146 | 147 | # Add any paths that contain custom static files (such as style sheets) here, 148 | # relative to this directory. They are copied after the builtin static files, 149 | # so a file named "default.css" will overwrite the builtin "default.css". 150 | html_static_path = [] 151 | 152 | # Add any extra paths that contain custom files (such as robots.txt or 153 | # .htaccess) here, relative to this directory. These files are copied 154 | # directly to the root of the documentation. 155 | #html_extra_path = [] 156 | 157 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 158 | # using the given strftime format. 159 | #html_last_updated_fmt = '%b %d, %Y' 160 | 161 | # If true, SmartyPants will be used to convert quotes and dashes to 162 | # typographically correct entities. 163 | #html_use_smartypants = True 164 | 165 | # Custom sidebar templates, maps document names to template names. 166 | #html_sidebars = {} 167 | 168 | # Additional templates that should be rendered to pages, maps page names to 169 | # template names. 170 | #html_additional_pages = {} 171 | 172 | # If false, no module index is generated. 173 | #html_domain_indices = True 174 | 175 | # If false, no index is generated. 176 | #html_use_index = True 177 | 178 | # If true, the index is split into individual pages for each letter. 179 | #html_split_index = False 180 | 181 | # If true, links to the reST sources are added to the pages. 182 | #html_show_sourcelink = True 183 | 184 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 185 | #html_show_sphinx = True 186 | 187 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 188 | #html_show_copyright = True 189 | 190 | # If true, an OpenSearch description file will be output, and all pages will 191 | # contain a tag referring to it. The value of this option must be the 192 | # base URL from which the finished HTML is served. 193 | #html_use_opensearch = '' 194 | 195 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 196 | #html_file_suffix = None 197 | 198 | # Language to be used for generating the HTML full-text search index. 199 | # Sphinx supports the following languages: 200 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 201 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 202 | #html_search_language = 'en' 203 | 204 | # A dictionary with options for the search language support, empty by default. 205 | # Now only 'ja' uses this config value 206 | #html_search_options = {'type': 'default'} 207 | 208 | # The name of a javascript file (relative to the configuration directory) that 209 | # implements a search results scorer. If empty, the default will be used. 210 | #html_search_scorer = 'scorer.js' 211 | 212 | # Output file base name for HTML help builder. 213 | htmlhelp_basename = 'pymediainfodoc' 214 | 215 | # -- Options for LaTeX output --------------------------------------------- 216 | 217 | latex_elements = { 218 | # The paper size ('letterpaper' or 'a4paper'). 219 | #'papersize': 'letterpaper', 220 | 221 | # The font size ('10pt', '11pt' or '12pt'). 222 | #'pointsize': '10pt', 223 | 224 | # Additional stuff for the LaTeX preamble. 225 | #'preamble': '', 226 | 227 | # Latex figure (float) alignment 228 | #'figure_align': 'htbp', 229 | } 230 | 231 | # Grouping the document tree into LaTeX files. List of tuples 232 | # (source start file, target name, title, 233 | # author, documentclass [howto, manual, or own class]). 234 | latex_documents = [ 235 | (master_doc, 'pymediainfo.tex', 'pymediainfo Documentation', 236 | 'Patrick Altman, Louis Sautier', 'manual'), 237 | ] 238 | 239 | # The name of an image file (relative to this directory) to place at the top of 240 | # the title page. 241 | #latex_logo = None 242 | 243 | # For "manual" documents, if this is true, then toplevel headings are parts, 244 | # not chapters. 245 | #latex_use_parts = False 246 | 247 | # If true, show page references after internal links. 248 | #latex_show_pagerefs = False 249 | 250 | # If true, show URL addresses after external links. 251 | #latex_show_urls = False 252 | 253 | # Documents to append as an appendix to all manuals. 254 | #latex_appendices = [] 255 | 256 | # If false, no module index is generated. 257 | #latex_domain_indices = True 258 | 259 | 260 | # -- Options for manual page output --------------------------------------- 261 | 262 | # One entry per manual page. List of tuples 263 | # (source start file, name, description, authors, manual section). 264 | man_pages = [ 265 | (master_doc, 'pymediainfo', 'pymediainfo Documentation', 266 | [author], 1) 267 | ] 268 | 269 | # If true, show URL addresses after external links. 270 | #man_show_urls = False 271 | 272 | 273 | # -- Options for Texinfo output ------------------------------------------- 274 | 275 | # Grouping the document tree into Texinfo files. List of tuples 276 | # (source start file, target name, title, author, 277 | # dir menu entry, description, category) 278 | texinfo_documents = [ 279 | (master_doc, 'pymediainfo', 'pymediainfo Documentation', 280 | author, 'pymediainfo', 'One line description of project.', 281 | 'Miscellaneous'), 282 | ] 283 | 284 | # Documents to append as an appendix to all manuals. 285 | #texinfo_appendices = [] 286 | 287 | # If false, no module index is generated. 288 | #texinfo_domain_indices = True 289 | 290 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 291 | #texinfo_show_urls = 'footnote' 292 | 293 | # If true, do not generate a @detailmenu in the "Top" node's menu. 294 | #texinfo_no_detailmenu = False 295 | 296 | # See https://github.com/executablebooks/MyST-Parser/discussions/898 297 | suppress_warnings = ["myst.header"] 298 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to pymediainfo's documentation! 2 | ======================================= 3 | 4 | This is the documentation for version |version|. 5 | 6 | .. toctree:: 7 | 8 | introduction 9 | pymediainfo 10 | 11 | * :ref:`genindex` 12 | * :ref:`search` 13 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | .. Start at line 2 to skip the title 5 | .. include:: ../README.md 6 | :parser: myst_parser.sphinx_ 7 | :start-line: 2 8 | -------------------------------------------------------------------------------- /docs/pymediainfo.rst: -------------------------------------------------------------------------------- 1 | API reference 2 | ============= 3 | 4 | .. automodule:: pymediainfo 5 | :members: 6 | :undoc-members: 7 | 8 | .. _library_autodetection: 9 | 10 | Library autodetection 11 | ===================== 12 | 13 | Whenever a method is called with `library_file=None` (the default), the following logic is used. 14 | 15 | Filename Determination 16 | ----------------------- 17 | 18 | First, the library filename is automatically determined based on the operating system: 19 | 20 | - On **Linux**, `libmediainfo.so.0` is used. 21 | - On **macOS**, the first available file among `libmediainfo.0.dylib` and `libmediainfo.dylib` is used, in that order. 22 | - On **Windows**, `MediaInfo.dll` is used. 23 | 24 | Paths Searched 25 | -------------- 26 | 27 | Next, the code checks if a matching library file exists in the same directory 28 | as pymediainfo's ``__init__.py`` and attempts to load it. 29 | If pymediainfo was installed from a wheel, the bundled library is placed in this location. 30 | 31 | Last, if no matching file could be loaded from the previous location, the code attempts to 32 | load the previously determined filename(s) from standard system paths, using 33 | :class:`ctypes.CDLL` for Linux and macOS, or :class:`ctypes.WinDLL` for 34 | Windows. 35 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster 2 | myst-parser 3 | setuptools_scm 4 | sphinx 5 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Specify a score threshold to be exceeded before program exits with error. 9 | fail-under=10.0 10 | 11 | # Add files or directories to the blacklist. They should be base names, not 12 | # paths. 13 | ignore=CVS 14 | 15 | # Add files or directories matching the regex patterns to the blacklist. The 16 | # regex matches against base names, not paths. 17 | ignore-patterns= 18 | 19 | # Python code to execute, usually for sys.path manipulation such as 20 | # pygtk.require(). 21 | #init-hook= 22 | 23 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 24 | # number of processors available to use. 25 | jobs=1 26 | 27 | # Control the amount of potential inferred values when inferring a single 28 | # object. This can help the performance when dealing with large functions or 29 | # complex, nested conditions. 30 | limit-inference-results=100 31 | 32 | # List of plugins (as comma separated values of python module names) to load, 33 | # usually to register additional checkers. 34 | load-plugins= 35 | 36 | # Pickle collected data for later comparisons. 37 | persistent=yes 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=consider-using-f-string 64 | 65 | # Enable the message, report, category or checker with the given id(s). You can 66 | # either give multiple identifier separated by comma (,) or put this option 67 | # multiple time (only on the command line, not in the configuration file where 68 | # it should appear only once). See also the "--disable" option for examples. 69 | enable=c-extension-no-member 70 | 71 | 72 | [REPORTS] 73 | 74 | # Python expression which should return a score less than or equal to 10. You 75 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 76 | # which contain the number of messages in each category, as well as 'statement' 77 | # which is the total number of statements analyzed. This score is used by the 78 | # global evaluation report (RP0004). 79 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 80 | 81 | # Template used to display messages. This is a python new-style format string 82 | # used to format the message information. See doc for all details. 83 | #msg-template= 84 | 85 | # Set the output format. Available formats are text, parseable, colorized, json 86 | # and msvs (visual studio). You can also give a reporter class, e.g. 87 | # mypackage.mymodule.MyReporterClass. 88 | output-format=text 89 | 90 | # Tells whether to display a full report or only the messages. 91 | reports=no 92 | 93 | # Activate the evaluation score. 94 | score=yes 95 | 96 | 97 | [REFACTORING] 98 | 99 | # Maximum number of nested blocks for function / method body 100 | max-nested-blocks=5 101 | 102 | # Complete name of functions that never returns. When checking for 103 | # inconsistent-return-statements if a never returning function is called then 104 | # it will be considered as an explicit return statement and no message will be 105 | # printed. 106 | never-returning-functions=sys.exit 107 | 108 | 109 | [FORMAT] 110 | 111 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 112 | expected-line-ending-format= 113 | 114 | # Regexp for a line that is allowed to be longer than the limit. 115 | ignore-long-lines=^\s*(# )??$ 116 | 117 | # Number of spaces of indent required inside a hanging or continued line. 118 | indent-after-paren=4 119 | 120 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 121 | # tab). 122 | indent-string=' ' 123 | 124 | # Maximum number of characters on a single line. 125 | max-line-length=100 126 | 127 | # Maximum number of lines in a module. 128 | max-module-lines=1000 129 | 130 | # Allow the body of a class to be on the same line as the declaration if body 131 | # contains single statement. 132 | single-line-class-stmt=no 133 | 134 | # Allow the body of an if to be on the same line as the test if there is no 135 | # else. 136 | single-line-if-stmt=no 137 | 138 | 139 | [MISCELLANEOUS] 140 | 141 | # List of note tags to take in consideration, separated by a comma. 142 | notes=FIXME, 143 | XXX, 144 | TODO 145 | 146 | # Regular expression of note tags to take in consideration. 147 | #notes-rgx= 148 | 149 | 150 | [BASIC] 151 | 152 | # Naming style matching correct argument names. 153 | argument-naming-style=snake_case 154 | 155 | # Regular expression matching correct argument names. Overrides argument- 156 | # naming-style. 157 | #argument-rgx= 158 | 159 | # Naming style matching correct attribute names. 160 | attr-naming-style=snake_case 161 | 162 | # Regular expression matching correct attribute names. Overrides attr-naming- 163 | # style. 164 | #attr-rgx= 165 | 166 | # Bad variable names which should always be refused, separated by a comma. 167 | bad-names=foo, 168 | bar, 169 | baz, 170 | toto, 171 | tutu, 172 | tata 173 | 174 | # Bad variable names regexes, separated by a comma. If names match any regex, 175 | # they will always be refused 176 | bad-names-rgxs= 177 | 178 | # Naming style matching correct class attribute names. 179 | class-attribute-naming-style=any 180 | 181 | # Regular expression matching correct class attribute names. Overrides class- 182 | # attribute-naming-style. 183 | #class-attribute-rgx= 184 | 185 | # Naming style matching correct class names. 186 | class-naming-style=PascalCase 187 | 188 | # Regular expression matching correct class names. Overrides class-naming- 189 | # style. 190 | #class-rgx= 191 | 192 | # Naming style matching correct constant names. 193 | const-naming-style=UPPER_CASE 194 | 195 | # Regular expression matching correct constant names. Overrides const-naming- 196 | # style. 197 | #const-rgx= 198 | 199 | # Minimum line length for functions/classes that require docstrings, shorter 200 | # ones are exempt. 201 | docstring-min-length=-1 202 | 203 | # Naming style matching correct function names. 204 | function-naming-style=snake_case 205 | 206 | # Regular expression matching correct function names. Overrides function- 207 | # naming-style. 208 | #function-rgx= 209 | 210 | # Good variable names which should always be accepted, separated by a comma. 211 | good-names=i, 212 | j, 213 | k, 214 | ex, 215 | Run, 216 | _, 217 | f 218 | 219 | # Good variable names regexes, separated by a comma. If names match any regex, 220 | # they will always be accepted 221 | good-names-rgxs= 222 | 223 | # Include a hint for the correct naming format with invalid-name. 224 | include-naming-hint=no 225 | 226 | # Naming style matching correct inline iteration names. 227 | inlinevar-naming-style=any 228 | 229 | # Regular expression matching correct inline iteration names. Overrides 230 | # inlinevar-naming-style. 231 | #inlinevar-rgx= 232 | 233 | # Naming style matching correct method names. 234 | method-naming-style=snake_case 235 | 236 | # Regular expression matching correct method names. Overrides method-naming- 237 | # style. 238 | #method-rgx= 239 | 240 | # Naming style matching correct module names. 241 | module-naming-style=snake_case 242 | 243 | # Regular expression matching correct module names. Overrides module-naming- 244 | # style. 245 | #module-rgx= 246 | 247 | # Colon-delimited sets of names that determine each other's naming style when 248 | # the name regexes allow several styles. 249 | name-group= 250 | 251 | # Regular expression which should only match function or class names that do 252 | # not require a docstring. 253 | no-docstring-rgx=^_ 254 | 255 | # List of decorators that produce properties, such as abc.abstractproperty. Add 256 | # to this list to register other decorators that produce valid properties. 257 | # These decorators are taken in consideration only for invalid-name. 258 | property-classes=abc.abstractproperty 259 | 260 | # Naming style matching correct variable names. 261 | variable-naming-style=snake_case 262 | 263 | # Regular expression matching correct variable names. Overrides variable- 264 | # naming-style. 265 | #variable-rgx= 266 | 267 | 268 | [TYPECHECK] 269 | 270 | # List of decorators that produce context managers, such as 271 | # contextlib.contextmanager. Add to this list to register other decorators that 272 | # produce valid context managers. 273 | contextmanager-decorators=contextlib.contextmanager 274 | 275 | # List of members which are set dynamically and missed by pylint inference 276 | # system, and so shouldn't trigger E1101 when accessed. Python regular 277 | # expressions are accepted. 278 | generated-members= 279 | 280 | # Tells whether missing members accessed in mixin class should be ignored. A 281 | # mixin class is detected if its name ends with "mixin" (case insensitive). 282 | ignore-mixin-members=yes 283 | 284 | # Tells whether to warn about missing members when the owner of the attribute 285 | # is inferred to be None. 286 | ignore-none=yes 287 | 288 | # This flag controls whether pylint should warn about no-member and similar 289 | # checks whenever an opaque object is returned when inferring. The inference 290 | # can return multiple potential results while evaluating a Python object, but 291 | # some branches might not be evaluated, which results in partial inference. In 292 | # that case, it might be useful to still emit no-member and other checks for 293 | # the rest of the inferred objects. 294 | ignore-on-opaque-inference=yes 295 | 296 | # List of class names for which member attributes should not be checked (useful 297 | # for classes with dynamically set attributes). This supports the use of 298 | # qualified names. 299 | ignored-classes=optparse.Values,thread._local,_thread._local 300 | 301 | # List of module names for which member attributes should not be checked 302 | # (useful for modules/projects where namespaces are manipulated during runtime 303 | # and thus existing member attributes cannot be deduced by static analysis). It 304 | # supports qualified module names, as well as Unix pattern matching. 305 | ignored-modules= 306 | 307 | # Show a hint with possible names when a member name was not found. The aspect 308 | # of finding the hint is based on edit distance. 309 | missing-member-hint=yes 310 | 311 | # The minimum edit distance a name should have in order to be considered a 312 | # similar match for a missing member name. 313 | missing-member-hint-distance=1 314 | 315 | # The total number of similar names that should be taken in consideration when 316 | # showing a hint for a missing member. 317 | missing-member-max-choices=1 318 | 319 | # List of decorators that change the signature of a decorated function. 320 | signature-mutators= 321 | 322 | 323 | [LOGGING] 324 | 325 | # The type of string formatting that logging methods do. `old` means using % 326 | # formatting, `new` is for `{}` formatting. 327 | logging-format-style=old 328 | 329 | # Logging modules to check that the string format arguments are in logging 330 | # function parameter format. 331 | logging-modules=logging 332 | 333 | 334 | [STRING] 335 | 336 | # This flag controls whether inconsistent-quotes generates a warning when the 337 | # character used as a quote delimiter is used inconsistently within a module. 338 | check-quote-consistency=no 339 | 340 | # This flag controls whether the implicit-str-concat should generate a warning 341 | # on implicit string concatenation in sequences defined over several lines. 342 | check-str-concat-over-line-jumps=no 343 | 344 | 345 | [SPELLING] 346 | 347 | # Limits count of emitted suggestions for spelling mistakes. 348 | max-spelling-suggestions=4 349 | 350 | # Spelling dictionary name. Available dictionaries: none. To make it work, 351 | # install the python-enchant package. 352 | spelling-dict= 353 | 354 | # List of comma separated words that should not be checked. 355 | spelling-ignore-words= 356 | 357 | # A path to a file that contains the private dictionary; one word per line. 358 | spelling-private-dict-file= 359 | 360 | # Tells whether to store unknown words to the private dictionary (see the 361 | # --spelling-private-dict-file option) instead of raising a message. 362 | spelling-store-unknown-words=no 363 | 364 | 365 | [VARIABLES] 366 | 367 | # List of additional names supposed to be defined in builtins. Remember that 368 | # you should avoid defining new builtins when possible. 369 | additional-builtins= 370 | 371 | # Tells whether unused global variables should be treated as a violation. 372 | allow-global-unused-variables=yes 373 | 374 | # List of strings which can identify a callback function by name. A callback 375 | # name must start or end with one of those strings. 376 | callbacks=cb_, 377 | _cb 378 | 379 | # A regular expression matching the name of dummy variables (i.e. expected to 380 | # not be used). 381 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 382 | 383 | # Argument names that match this expression will be ignored. Default to name 384 | # with leading underscore. 385 | ignored-argument-names=_.*|^ignored_|^unused_ 386 | 387 | # Tells whether we should check for unused import in __init__ files. 388 | init-import=no 389 | 390 | # List of qualified module names which can have objects that can redefine 391 | # builtins. 392 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 393 | 394 | 395 | [SIMILARITIES] 396 | 397 | # Ignore comments when computing similarities. 398 | ignore-comments=yes 399 | 400 | # Ignore docstrings when computing similarities. 401 | ignore-docstrings=yes 402 | 403 | # Ignore imports when computing similarities. 404 | ignore-imports=no 405 | 406 | # Minimum lines number of a similarity. 407 | min-similarity-lines=4 408 | 409 | 410 | [DESIGN] 411 | 412 | # Maximum number of arguments for function / method. 413 | max-args=5 414 | 415 | # Maximum number of attributes for a class (see R0902). 416 | max-attributes=7 417 | 418 | # Maximum number of boolean expressions in an if statement (see R0916). 419 | max-bool-expr=5 420 | 421 | # Maximum number of branch for function / method body. 422 | max-branches=12 423 | 424 | # Maximum number of locals for function / method body. 425 | max-locals=15 426 | 427 | # Maximum number of parents for a class (see R0901). 428 | max-parents=7 429 | 430 | # Maximum number of public methods for a class (see R0904). 431 | max-public-methods=20 432 | 433 | # Maximum number of return / yield for function / method body. 434 | max-returns=6 435 | 436 | # Maximum number of statements in function / method body. 437 | max-statements=50 438 | 439 | # Minimum number of public methods for a class (see R0903). 440 | min-public-methods=2 441 | 442 | 443 | [CLASSES] 444 | 445 | # List of method names used to declare (i.e. assign) instance attributes. 446 | defining-attr-methods=__init__, 447 | __new__, 448 | setUp, 449 | __post_init__ 450 | 451 | # List of member names, which should be excluded from the protected access 452 | # warning. 453 | exclude-protected=_asdict, 454 | _fields, 455 | _replace, 456 | _source, 457 | _make 458 | 459 | # List of valid names for the first argument in a class method. 460 | valid-classmethod-first-arg=cls 461 | 462 | # List of valid names for the first argument in a metaclass class method. 463 | valid-metaclass-classmethod-first-arg=cls 464 | 465 | 466 | [IMPORTS] 467 | 468 | # List of modules that can be imported at any level, not just the top level 469 | # one. 470 | allow-any-import-level= 471 | 472 | # Allow wildcard imports from modules that define __all__. 473 | allow-wildcard-with-all=no 474 | 475 | # Analyse import fallback blocks. This can be used to support both Python 2 and 476 | # 3 compatible code, which means that the block might have code that exists 477 | # only in one or another interpreter, leading to false positives when analysed. 478 | analyse-fallback-blocks=no 479 | 480 | # Deprecated modules which should not be used, separated by a comma. 481 | deprecated-modules=optparse,tkinter.tix 482 | 483 | # Create a graph of external dependencies in the given file (report RP0402 must 484 | # not be disabled). 485 | ext-import-graph= 486 | 487 | # Create a graph of every (i.e. internal and external) dependencies in the 488 | # given file (report RP0402 must not be disabled). 489 | import-graph= 490 | 491 | # Create a graph of internal dependencies in the given file (report RP0402 must 492 | # not be disabled). 493 | int-import-graph= 494 | 495 | # Force import order to recognize a module as part of the standard 496 | # compatibility libraries. 497 | known-standard-library= 498 | 499 | # Force import order to recognize a module as part of a third party library. 500 | known-third-party=enchant 501 | 502 | # Couples of modules and preferred modules, separated by a comma. 503 | preferred-modules= 504 | 505 | 506 | [EXCEPTIONS] 507 | 508 | # Exceptions that will emit a warning when being caught. Defaults to 509 | # "BaseException, Exception". 510 | overgeneral-exceptions=builtins.BaseException, 511 | builtins.Exception 512 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # https://packaging.python.org/en/latest/specifications/pyproject-toml/ 2 | [build-system] 3 | requires = ["pdm-backend", "wheel>=0.42"] 4 | build-backend = "pdm.backend" 5 | 6 | [project] 7 | name = "pymediainfo" 8 | description = "A Python wrapper for the MediaInfo library." 9 | authors = [ 10 | {name = "Louis Sautier", email = "sautier.louis@gmail.com"}, 11 | ] 12 | readme = "README.md" 13 | license = {text = "MIT"} 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: MacOS :: MacOS X", 18 | "Operating System :: Microsoft :: Windows", 19 | "Operating System :: POSIX :: Linux", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: Implementation :: CPython", 27 | "Programming Language :: Python :: Implementation :: PyPy", 28 | ] 29 | dynamic = ["version"] 30 | requires-python = ">=3.9" 31 | dependencies = [ 32 | ] 33 | 34 | [project.optional-dependencies] 35 | tests = [ 36 | "pytest>=6", 37 | "pytest-cov", 38 | "pytest-xdist", 39 | ] 40 | docs = [ 41 | "alabaster", 42 | "myst-parser", 43 | "setuptools_scm", 44 | "sphinx", 45 | ] 46 | dev = [ 47 | "ipython", 48 | "mypy>=1.0", 49 | "black", 50 | "isort", 51 | "flake8", 52 | "pylint", 53 | ] 54 | 55 | [project.urls] 56 | Homepage = "https://github.com/sbraz/pymediainfo" 57 | Documentation = "https://pymediainfo.readthedocs.io/" 58 | Bugs = "https://github.com/sbraz/pymediainfo/issues" 59 | 60 | 61 | # https://pdm-project.org/latest/ 62 | [tool.pdm.version] 63 | source = "scm" 64 | 65 | [tool.pdm.dev-dependencies] 66 | download_library = ["wheel>=0.44", "requests"] 67 | 68 | [tool.pdm.build] 69 | source-includes = ["docs/", "scripts/", "tests/"] 70 | 71 | [tool.pdm.scripts.types] 72 | help = "Check type hints" 73 | cmd = "mypy --install-types --non-interactive --config-file=pyproject.toml {args:src tests}" 74 | 75 | [tool.pdm.scripts.docs] 76 | help = "Build and test documentation" 77 | composite = [ 78 | "sphinx-build -W --keep-going --color -b html docs docs/_build", 79 | "sphinx-build -W --keep-going --color -b linkcheck docs docs/_build", 80 | ] 81 | 82 | [tool.pdm.scripts.test-nocov] 83 | help = "Run tests without coverage" 84 | cmd = "pytest {args:-n auto}" 85 | 86 | [tool.pdm.scripts.test] 87 | help = "Run tests with coverage" 88 | cmd = "pytest --cov --cov-report=term-missing --cov-config=pyproject.toml {args:-n auto}" 89 | 90 | [tool.pdm.scripts.download_library] 91 | help = "Download mediainfo library" 92 | cmd = "python scripts/download_library.py {args:-c --auto}" 93 | 94 | [tool.pdm.scripts.clean_library] 95 | help = "Clean mediainfo library" 96 | cmd = "python scripts/download_library.py -c" 97 | 98 | [tool.pdm.scripts.tag_wheel] 99 | help = "Tag the wheels for a specific platform" 100 | cmd = "python scripts/tag_pure_wheels.py" 101 | 102 | [tool.pdm.scripts.build_linux_x86_64] 103 | help = "Build wheel with bundled library for Linux x86_64" 104 | composite = [ 105 | "download_library -c -p linux -a x86_64", 106 | "pdm build -v --no-sdist", 107 | "tag_wheel manylinux_2_27_x86_64", 108 | ] 109 | 110 | [tool.pdm.scripts.build_linux_arm64] 111 | help = "Build wheel with bundled library for Linux arm64" 112 | composite = [ 113 | "download_library -c -p linux -a arm64", 114 | "pdm build -v --no-sdist", 115 | "tag_wheel manylinux_2_27_aarch64", 116 | ] 117 | 118 | [tool.pdm.scripts.build_win32_x86_64] 119 | help = "Build wheel with bundled library for Windows x64" 120 | composite = [ 121 | "download_library -c -p win32 -a x86_64", 122 | "pdm build -v --no-sdist", 123 | "tag_wheel win_amd64", 124 | ] 125 | 126 | [tool.pdm.scripts.build_win32_i386] 127 | help = "Build wheel with bundled library for Windows x32" 128 | composite = [ 129 | "download_library -c -p win32 -a i386", 130 | "pdm build -v --no-sdist", 131 | "tag_wheel win32", 132 | ] 133 | 134 | [tool.pdm.scripts.build_darwin] 135 | help = "Build wheel with bundled library for MacOS x86_64 and arm64" 136 | composite = [ 137 | # -a doesn't matter as the file works with both x86_64 and arm64 (Mac_x86_64+arm64.tar.bz2) 138 | "download_library -c -p darwin -a arm64", 139 | "pdm build -v --no-sdist", 140 | "tag_wheel macosx_10_10_universal2", 141 | ] 142 | 143 | [tool.pdm.scripts.build_all] 144 | help = "Build all the wheels with bundled library and the sdist and wheel without library" 145 | composite = [ 146 | "build_linux_arm64", 147 | "build_linux_x86_64", 148 | "build_win32_x86_64", 149 | "build_win32_i386", 150 | "build_darwin", 151 | # remove any library before building sdist and barebone wheel 152 | "clean_library", 153 | "pdm build", 154 | ] 155 | 156 | 157 | # https://mypy.readthedocs.io/en/stable/config_file.html 158 | [tool.mypy] 159 | # global-only flags 160 | pretty = true 161 | show_error_codes = true 162 | 163 | [[tool.mypy.overrides]] 164 | module = ["pymediainfo.*"] 165 | strict = true 166 | 167 | 168 | [tool.pytest.ini_options] 169 | addopts = "-vv -r a" 170 | -------------------------------------------------------------------------------- /scripts/demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # ruff: noqa: T201 3 | """A demo that shows how to call pymediainfo.""" 4 | 5 | import argparse 6 | from pprint import pprint 7 | 8 | from pymediainfo import MediaInfo 9 | 10 | 11 | def process(media_file: str) -> None: 12 | print(f"Processing {media_file}") 13 | media_info = MediaInfo.parse(media_file) 14 | for track in media_info.tracks: 15 | if track.track_type == "General": 16 | print(f"The file format is {track.format}") 17 | print("General information dump:") 18 | pprint(track.to_data()) 19 | elif track.track_type == "Video": 20 | print( 21 | f"Video track {track.track_id} has a resolution of {track.width}×{track.height}", 22 | f"and a bit rate of {track.bit_rate} bits/s", 23 | ) 24 | elif track.track_type == "Audio": 25 | if track.duration is not None: 26 | print( 27 | f"Audio track {track.track_id} has a duration of {track.duration/1000} seconds" 28 | ) 29 | 30 | 31 | if __name__ == "__main__": 32 | parser = argparse.ArgumentParser(description=__doc__) 33 | parser.add_argument("media_file", nargs="+", help="media files to parse") 34 | args = parser.parse_args() 35 | for index, media_file in enumerate(args.media_file): 36 | if index != 0: 37 | print() 38 | process(media_file) 39 | -------------------------------------------------------------------------------- /scripts/download_library.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ruff: noqa: T201 4 | """Download binary library files from .""" 5 | 6 | from __future__ import annotations 7 | 8 | import hashlib 9 | import os 10 | import shutil 11 | import sys 12 | import tarfile 13 | from dataclasses import dataclass 14 | from pathlib import Path 15 | from tempfile import TemporaryDirectory 16 | from typing import TYPE_CHECKING, Any 17 | from zipfile import ZipFile 18 | 19 | import requests 20 | 21 | if TYPE_CHECKING: 22 | from typing import Literal 23 | 24 | 25 | #: Base URL for downloading MediaInfo library 26 | BASE_URL: str = "https://mediaarea.net/download/binary/libmediainfo0" 27 | 28 | #: Version of the bundled MediaInfo library 29 | MEDIAINFO_VERSION: str = "24.12" 30 | 31 | # fmt: off 32 | #: BLAKE2b hashes for the specific MediaInfo version, given the (platform, arch) 33 | MEDIAINFO_HASHES: dict[tuple[str, str], str] = { 34 | ("linux", "x86_64"): "13c4afb2948187cc06f13b1cd7d7a49f8618b8d1e3a440d9e96ef7b486a653d5e2567aae97dc253e3d3f484a7837e4b5a972abab4803223300a79c601c0bcce1", 35 | ("linux", "arm64"): "4e5a9826fa987f4bde46a6586894d45f3d5381f25d8886dfef67f5db3a9f4377ecafc12acc6e2d71e43b062b686c2db2523052a1f3dd7505a41091847788d114", 36 | # The same file is used for darwin x86_64 and arm64 (suffixed Mac_x86_64+arm64.tar.bz2) 37 | ("darwin", "x86_64"): "65b8195f0859369fa0ab1870cbde1535bb57f16bde451b22585d849c870f1ca92972328c11edd15b6f8187445dd58efff7107cfce39d2be7a88e8c434589b4ae", 38 | ("darwin", "arm64"): "65b8195f0859369fa0ab1870cbde1535bb57f16bde451b22585d849c870f1ca92972328c11edd15b6f8187445dd58efff7107cfce39d2be7a88e8c434589b4ae", 39 | ("win32", "x86_64"): "f831c588e9eaf51201b4cc7995dce66852591764fc5ef05effd3a8a2037ff5d37ec039eef5d1f990f05bd7452c1cad720e95b77a095f9f1a690689e351fc00b8", 40 | ("win32", "i386"): "0f0e14c103eac858fe683ec7d51634d62e5e0af658940fd26608249f1048846a92a334438204fe5ecfceb70cb00e5550bfb717a77f10816a2583b5041bb61790", 41 | } 42 | # fmt: on 43 | 44 | 45 | def get_file_blake2b(file_path: os.PathLike | str, chunksize: int = 1 << 20) -> str: 46 | """Get the BLAKE2b hash of a file.""" 47 | blake2b = hashlib.blake2b() 48 | with open(file_path, "rb") as f: 49 | while chunk := f.read(chunksize): 50 | blake2b.update(chunk) 51 | return blake2b.hexdigest() 52 | 53 | 54 | @dataclass 55 | class Downloader: 56 | """Downloader for the MediaInfo library files.""" 57 | 58 | platform: Literal["linux", "darwin", "win32"] 59 | arch: Literal["x86_64", "arm64", "i386"] 60 | 61 | def __post_init__(self) -> None: 62 | """Check that the combination of platform and arch is allowed.""" 63 | allowed_arch = None 64 | if self.platform in ("linux", "darwin"): 65 | allowed_arch = ["x86_64", "arm64"] 66 | elif self.platform == "win32": 67 | allowed_arch = ["x86_64", "i386"] 68 | else: 69 | raise ValueError(f"platform not recognized: {self.platform}") 70 | 71 | # Check the platform and arch is a valid combination 72 | if allowed_arch is not None and self.arch not in allowed_arch: 73 | raise ValueError( 74 | f"arch {self.arch} is not allowed for platform {self.platform}; " 75 | f"must be one of {allowed_arch}" 76 | ) 77 | 78 | def get_compressed_file_name(self) -> str: 79 | """Get the compressed file name.""" 80 | if self.platform == "linux": 81 | suffix = f"Lambda_{self.arch}.zip" 82 | elif self.platform == "darwin": 83 | suffix = "Mac_x86_64+arm64.tar.bz2" 84 | elif self.platform == "win32": 85 | win_arch = "x64" if self.arch == "x86_64" else self.arch 86 | suffix = f"Windows_{win_arch}_WithoutInstaller.zip" 87 | else: 88 | raise ValueError(f"platform not recognized: {self.platform}") 89 | 90 | return f"MediaInfo_DLL_{MEDIAINFO_VERSION}_{suffix}" 91 | 92 | def get_url(self) -> str: 93 | """Get the URL to download the MediaInfo library.""" 94 | compressed_file = self.get_compressed_file_name() 95 | return f"{BASE_URL}/{MEDIAINFO_VERSION}/{compressed_file}" 96 | 97 | def compare_hash(self, h: str) -> bool: 98 | """Compare downloaded hash with expected.""" 99 | key = (self.platform, self.arch) 100 | expected = MEDIAINFO_HASHES.get(key) 101 | # Check expected hash exists 102 | if expected is None: 103 | raise ValueError(f"{key}, expected hash not found.") 104 | 105 | # Check hashes match 106 | if expected != h: 107 | raise ValueError(f"hash mismatch for {key}: expected {expected}, got {h}") 108 | 109 | return True 110 | 111 | def download_upstream( 112 | self, 113 | url: str, 114 | outpath: os.PathLike, 115 | *, 116 | timeout: int = 20, 117 | verbose: bool = True, 118 | ) -> None: 119 | """Download the compressed file from upstream URL.""" 120 | response = requests.get(url, stream=True, timeout=timeout) 121 | response.raise_for_status() 122 | with open(outpath, "wb") as f: 123 | for chunk in response.iter_content(chunk_size=8192): 124 | f.write(chunk) 125 | 126 | downloaded_hash = get_file_blake2b(outpath) 127 | self.compare_hash(downloaded_hash) 128 | 129 | def unpack( 130 | self, 131 | file: os.PathLike | str, 132 | folder: os.PathLike | str, 133 | ) -> dict[str, str]: 134 | """Extract compressed files.""" 135 | file = Path(file) 136 | folder = Path(folder) 137 | compressed_file = self.get_compressed_file_name() 138 | 139 | if not file.is_file(): 140 | raise ValueError(f"compressed file not found: {file.name!r}") 141 | tmp_dir = file.parent 142 | 143 | license_file: Path | None = None 144 | lib_file: Path | None = None 145 | # Linux 146 | if compressed_file.endswith(".zip") and self.platform == "linux": 147 | with ZipFile(file) as fd: 148 | license_file = folder / "LICENSE" 149 | fd.extract("LICENSE", tmp_dir) 150 | shutil.move(os.fspath(tmp_dir / "LICENSE"), os.fspath(license_file)) 151 | 152 | lib_file = folder / "libmediainfo.so.0" 153 | fd.extract("lib/libmediainfo.so.0.0.0", tmp_dir) 154 | shutil.move(os.fspath(tmp_dir / "lib/libmediainfo.so.0.0.0"), os.fspath(lib_file)) 155 | 156 | # macOS (darwin) 157 | elif compressed_file.endswith(".tar.bz2") and self.platform == "darwin": 158 | with tarfile.open(file) as fd: 159 | kwargs: dict[str, Any] = {} 160 | # Set for security reasons, see 161 | # https://docs.python.org/3/library/tarfile.html#tarfile-extraction-filter 162 | if sys.version_info >= (3, 12): 163 | kwargs = {"filter": "data"} 164 | 165 | license_file = folder / "License.html" 166 | fd.extract("MediaInfoLib/License.html", tmp_dir, **kwargs) 167 | shutil.move( 168 | os.fspath(tmp_dir / "MediaInfoLib/License.html"), 169 | os.fspath(license_file), 170 | ) 171 | 172 | lib_file = folder / "libmediainfo.0.dylib" 173 | fd.extract("MediaInfoLib/libmediainfo.0.dylib", tmp_dir, **kwargs) 174 | shutil.move( 175 | os.fspath(tmp_dir / "MediaInfoLib/libmediainfo.0.dylib"), 176 | os.fspath(lib_file), 177 | ) 178 | 179 | # Windows (win32) 180 | elif compressed_file.endswith(".zip") and self.platform == "win32": 181 | with ZipFile(file) as fd: 182 | license_file = folder / "License.html" 183 | fd.extract("Developers/License.html", tmp_dir) 184 | shutil.move(os.fspath(tmp_dir / "Developers/License.html"), os.fspath(license_file)) 185 | 186 | lib_file = folder / "MediaInfo.dll" 187 | fd.extract("MediaInfo.dll", tmp_dir) 188 | shutil.move(os.fspath(tmp_dir / "MediaInfo.dll"), os.fspath(lib_file)) 189 | 190 | files = {} 191 | if license_file is not None and license_file.is_file(): 192 | files["license"] = os.fspath(license_file.relative_to(folder)) 193 | if lib_file is not None and lib_file.is_file(): 194 | files["lib"] = os.fspath(lib_file.relative_to(folder)) 195 | 196 | return files 197 | 198 | def download( 199 | self, 200 | folder: os.PathLike | str, 201 | *, 202 | timeout: int = 20, 203 | verbose: bool = True, 204 | ) -> dict[str, str]: 205 | """Download the library and license files.""" 206 | folder = Path(folder) 207 | 208 | url = self.get_url() 209 | compressed_file = self.get_compressed_file_name() 210 | 211 | extracted_files = {} 212 | with TemporaryDirectory() as tmp_dir: 213 | outpath = Path(tmp_dir) / compressed_file 214 | if verbose: 215 | print(f"Downloading MediaInfo library from {url}") 216 | self.download_upstream(url, outpath, timeout=timeout, verbose=verbose) 217 | 218 | if verbose: 219 | print(f"Extracting {compressed_file}") 220 | extracted_files = self.unpack(outpath, folder) 221 | 222 | if verbose: 223 | print(f"Extracted files: {extracted_files}") 224 | return extracted_files 225 | 226 | 227 | def download_files( 228 | folder: os.PathLike | str, 229 | platform: Literal["linux", "darwin", "win32"], 230 | arch: Literal["x86_64", "arm64", "i386"], 231 | *, 232 | timeout: int = 20, 233 | verbose: bool = True, 234 | ) -> dict[str, str]: 235 | """Download the library and license files to the output folder.""" 236 | downloader = Downloader(platform=platform, arch=arch) 237 | return downloader.download(folder, timeout=timeout, verbose=verbose) 238 | 239 | 240 | def clean_files( 241 | folder: os.PathLike | str, 242 | *, 243 | verbose: bool = True, 244 | ) -> bool: 245 | """Remove downloaded files in the output folder.""" 246 | folder = Path(folder) 247 | if not folder.is_dir(): 248 | if verbose: 249 | print(f"folder does not exist: {os.fspath(folder)!r}") 250 | return False 251 | 252 | glob_patterns = ["License.html", "LICENSE", "MediaInfo.dll", "libmediainfo.*"] 253 | 254 | # list files to delete 255 | to_delete: list[os.PathLike] = [] 256 | for pattern in glob_patterns: 257 | to_delete.extend(folder.glob(pattern)) 258 | 259 | # delete files 260 | if verbose: 261 | print(f"will delete files: {to_delete}") 262 | for relative_path in to_delete: 263 | (folder / relative_path).unlink() 264 | 265 | return True 266 | 267 | 268 | if __name__ == "__main__": 269 | import argparse 270 | import platform 271 | 272 | default_folder = Path(__file__).resolve().parent.parent / "src" / "pymediainfo" 273 | 274 | parser = argparse.ArgumentParser( 275 | description="download MediaInfo files from upstream.", 276 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 277 | ) 278 | parser.add_argument( 279 | "-p", 280 | "--platform", 281 | choices=["linux", "darwin", "win32"], 282 | help="platform of the library", 283 | ) 284 | parser.add_argument( 285 | "-a", 286 | "--arch", 287 | choices=["x86_64", "arm64", "i386"], 288 | help="architecture of the library", 289 | ) 290 | parser.add_argument( 291 | "-A", 292 | "--auto", 293 | action="store_true", 294 | help="use the current platform and architecture", 295 | ) 296 | parser.add_argument( 297 | "-q", 298 | "--quiet", 299 | help="hide progress messages", 300 | action="store_true", 301 | ) 302 | parser.add_argument( 303 | "--timeout", 304 | type=int, 305 | help="URL request timeout in seconds", 306 | default=20, 307 | ) 308 | parser.add_argument( 309 | "-o", 310 | "--folder", 311 | type=Path, 312 | help="output folder", 313 | default=default_folder, 314 | ) 315 | parser.add_argument( 316 | "-c", 317 | "--clean", 318 | action="store_true", 319 | help="clean the output folder of downloaded files.", 320 | ) 321 | 322 | args = parser.parse_args() 323 | 324 | if not any((args.auto, args.clean, args.platform and args.arch)): 325 | parser.error("either -A/--auto, -c/--clean or -a/--arch with -p/--platform must be used") 326 | 327 | if not args.folder.is_dir(): 328 | parser.error(f"{args.folder} does not exist or is not a folder") 329 | 330 | if args.auto: 331 | args.platform = platform.system().lower() 332 | args.arch = platform.machine().lower() 333 | 334 | # Clean folder 335 | if args.clean: 336 | clean_files(args.folder, verbose=not args.quiet) 337 | 338 | # Download files 339 | if args.platform is not None and args.arch is not None: 340 | extracted_files = download_files( 341 | args.folder, 342 | args.platform, 343 | args.arch, 344 | verbose=not args.quiet, 345 | timeout=args.timeout, 346 | ) 347 | 348 | sys.exit(0) 349 | -------------------------------------------------------------------------------- /scripts/tag_pure_wheels.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ruff: noqa: T201 4 | """Tags all pure Python wheels from the 'dist' folder.""" 5 | 6 | import argparse 7 | import pathlib 8 | 9 | from wheel.cli.tags import tags 10 | 11 | if __name__ == "__main__": 12 | parser = argparse.ArgumentParser(description=__doc__) 13 | parser.add_argument("platform_tag", help="the tag to add") 14 | args = parser.parse_args() 15 | 16 | for wheel_path in pathlib.Path("dist").glob("*-py3-none-any.whl"): 17 | new_wheel = tags(wheel_path, platform_tags=args.platform_tag, remove=True) 18 | print(f"Tagged {wheel_path.name} -> {new_wheel}") 19 | -------------------------------------------------------------------------------- /src/pymediainfo/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a wrapper for the MediaInfo library. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import ctypes 8 | import json 9 | import os 10 | import pathlib 11 | import re 12 | import sys 13 | import warnings 14 | import xml.etree.ElementTree as ET 15 | from importlib import metadata 16 | from typing import Any, overload 17 | 18 | try: 19 | __version__ = metadata.version("pymediainfo") 20 | except metadata.PackageNotFoundError: 21 | __version__ = "" 22 | 23 | 24 | class Track: 25 | """ 26 | An object associated with a media file track. 27 | 28 | Each :class:`Track` attribute corresponds to attributes parsed from MediaInfo's output. 29 | All attributes are lower case. Attributes that are present several times such as `Duration` 30 | yield a second attribute starting with `other_` which is a list of all alternative 31 | attribute values. 32 | 33 | When a non-existing attribute is accessed, `None` is returned. 34 | 35 | Example: 36 | 37 | >>> t = mi.tracks[0] 38 | >>> t 39 | 40 | >>> t.duration 41 | 3000 42 | >>> t.other_duration 43 | ['3 s 0 ms', '3 s 0 ms', '3 s 0 ms', 44 | '00:00:03.000', '00:00:03.000'] 45 | >>> type(t.non_existing) 46 | NoneType 47 | 48 | All available attributes can be obtained by calling :func:`to_data`. 49 | """ 50 | 51 | def __eq__(self, other: object) -> bool: 52 | if not isinstance(other, Track): 53 | return False 54 | return self.__dict__ == other.__dict__ 55 | 56 | def __getattribute__(self, name: str) -> Any: 57 | try: 58 | return object.__getattribute__(self, name) 59 | except AttributeError: 60 | pass 61 | return None 62 | 63 | def __getstate__(self) -> dict[str, Any]: 64 | return self.__dict__ 65 | 66 | def __setstate__(self, state: dict[str, Any]) -> None: 67 | self.__dict__ = state 68 | 69 | def __init__(self, xml_dom_fragment: ET.Element) -> None: 70 | self.track_type = xml_dom_fragment.attrib["type"] 71 | repeated_attributes = [] 72 | for elem in xml_dom_fragment: 73 | node_name = elem.tag.lower().strip().strip("_") 74 | if node_name == "id": 75 | node_name = "track_id" 76 | node_value = elem.text 77 | if getattr(self, node_name) is None: 78 | setattr(self, node_name, node_value) 79 | else: 80 | other_node_name = f"other_{node_name}" 81 | repeated_attributes.append((node_name, other_node_name)) 82 | if getattr(self, other_node_name) is None: 83 | setattr(self, other_node_name, [node_value]) 84 | else: 85 | getattr(self, other_node_name).append(node_value) 86 | 87 | for primary_key, other_key in repeated_attributes: 88 | try: 89 | # Attempt to convert the main value to int 90 | # Usually, if an attribute is repeated, one of its value 91 | # is an int and others are human-readable formats 92 | setattr(self, primary_key, int(getattr(self, primary_key))) 93 | except ValueError: 94 | # If it fails, try to find a secondary value 95 | # that is an int and swap it with the main value 96 | for other_value in getattr(self, other_key): 97 | try: 98 | current = getattr(self, primary_key) 99 | # Set the main value to an int 100 | setattr(self, primary_key, int(other_value)) 101 | # Append its previous value to other values 102 | getattr(self, other_key).append(current) 103 | break 104 | except ValueError: 105 | pass 106 | 107 | def __repr__(self) -> str: 108 | return "".format(self.track_id, self.track_type) 109 | 110 | def to_data(self) -> dict[str, Any]: 111 | """ 112 | Returns a dict representation of the track attributes. 113 | 114 | Example: 115 | 116 | >>> sorted(track.to_data().keys())[:3] 117 | ['codec', 'codec_extensions_usually_used', 'codec_url'] 118 | >>> t.to_data()["file_size"] 119 | 5988 120 | 121 | 122 | :rtype: dict 123 | """ 124 | return self.__dict__ 125 | 126 | 127 | class MediaInfo: 128 | """ 129 | An object containing information about a media file. 130 | 131 | 132 | :class:`MediaInfo` objects can be created by directly calling code from 133 | libmediainfo (in this case, the library must be present on the system): 134 | 135 | >>> pymediainfo.MediaInfo.parse("/path/to/file.mp4") 136 | 137 | Alternatively, objects may be created from MediaInfo's XML output. 138 | Such output can be obtained using the ``XML`` output format on versions older than v17.10 139 | and the ``OLDXML`` format on newer versions. 140 | 141 | Using such an XML file, we can create a :class:`MediaInfo` object: 142 | 143 | >>> with open("output.xml") as f: 144 | ... mi = pymediainfo.MediaInfo(f.read()) 145 | 146 | :param str xml: XML output obtained from MediaInfo. 147 | :param str encoding_errors: option to pass to :func:`str.encode`'s `errors` 148 | parameter before parsing `xml`. 149 | :raises xml.etree.ElementTree.ParseError: if passed invalid XML. 150 | :var tracks: A list of :py:class:`Track` objects which the media file contains. 151 | For instance: 152 | 153 | >>> mi = pymediainfo.MediaInfo.parse("/path/to/file.mp4") 154 | >>> for t in mi.tracks: 155 | ... print(t) 156 | 157 | 158 | """ 159 | 160 | def __eq__(self, other: object) -> bool: 161 | if not isinstance(other, MediaInfo): 162 | return False 163 | return self.tracks == other.tracks 164 | 165 | def __init__(self, xml: str, encoding_errors: str = "strict") -> None: 166 | xml_dom = ET.fromstring(xml.encode("utf-8", encoding_errors)) 167 | self.tracks = [] 168 | # This is the case for libmediainfo < 18.03 169 | # https://github.com/sbraz/pymediainfo/issues/57 170 | # https://github.com/MediaArea/MediaInfoLib/commit/575a9a32e6960ea34adb3bc982c64edfa06e95eb 171 | if xml_dom.tag == "File": 172 | xpath = "track" 173 | else: 174 | xpath = "File/track" 175 | for xml_track in xml_dom.iterfind(xpath): 176 | self.tracks.append(Track(xml_track)) 177 | 178 | def _tracks(self, track_type: str) -> list[Track]: 179 | return [track for track in self.tracks if track.track_type == track_type] 180 | 181 | @property 182 | def general_tracks(self) -> list[Track]: 183 | """ 184 | :return: All :class:`Track`\\s of type ``General``. 185 | :rtype: list of :class:`Track`\\s 186 | """ 187 | return self._tracks("General") 188 | 189 | @property 190 | def video_tracks(self) -> list[Track]: 191 | """ 192 | :return: All :class:`Track`\\s of type ``Video``. 193 | :rtype: list of :class:`Track`\\s 194 | """ 195 | return self._tracks("Video") 196 | 197 | @property 198 | def audio_tracks(self) -> list[Track]: 199 | """ 200 | :return: All :class:`Track`\\s of type ``Audio``. 201 | :rtype: list of :class:`Track`\\s 202 | """ 203 | return self._tracks("Audio") 204 | 205 | @property 206 | def text_tracks(self) -> list[Track]: 207 | """ 208 | :return: All :class:`Track`\\s of type ``Text``. 209 | :rtype: list of :class:`Track`\\s 210 | """ 211 | return self._tracks("Text") 212 | 213 | @property 214 | def other_tracks(self) -> list[Track]: 215 | """ 216 | :return: All :class:`Track`\\s of type ``Other``. 217 | :rtype: list of :class:`Track`\\s 218 | """ 219 | return self._tracks("Other") 220 | 221 | @property 222 | def image_tracks(self) -> list[Track]: 223 | """ 224 | :return: All :class:`Track`\\s of type ``Image``. 225 | :rtype: list of :class:`Track`\\s 226 | """ 227 | return self._tracks("Image") 228 | 229 | @property 230 | def menu_tracks(self) -> list[Track]: 231 | """ 232 | :return: All :class:`Track`\\s of type ``Menu``. 233 | :rtype: list of :class:`Track`\\s 234 | """ 235 | return self._tracks("Menu") 236 | 237 | @staticmethod 238 | def _normalize_filename(filename: Any) -> Any: 239 | if hasattr(os, "PathLike") and isinstance(filename, os.PathLike): 240 | return os.fspath(filename) 241 | if pathlib is not None and isinstance(filename, pathlib.PurePath): 242 | return str(filename) 243 | return filename 244 | 245 | @classmethod 246 | def _define_library_prototypes(cls, lib: Any) -> Any: 247 | lib.MediaInfo_Inform.restype = ctypes.c_wchar_p 248 | lib.MediaInfo_New.argtypes = [] 249 | lib.MediaInfo_New.restype = ctypes.c_void_p 250 | lib.MediaInfo_Option.argtypes = [ 251 | ctypes.c_void_p, 252 | ctypes.c_wchar_p, 253 | ctypes.c_wchar_p, 254 | ] 255 | lib.MediaInfo_Option.restype = ctypes.c_wchar_p 256 | lib.MediaInfo_Inform.argtypes = [ctypes.c_void_p, ctypes.c_size_t] 257 | lib.MediaInfo_Inform.restype = ctypes.c_wchar_p 258 | lib.MediaInfo_Open.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p] 259 | lib.MediaInfo_Open.restype = ctypes.c_size_t 260 | lib.MediaInfo_Open_Buffer_Init.argtypes = [ 261 | ctypes.c_void_p, 262 | ctypes.c_uint64, 263 | ctypes.c_uint64, 264 | ] 265 | lib.MediaInfo_Open_Buffer_Init.restype = ctypes.c_size_t 266 | lib.MediaInfo_Open_Buffer_Continue.argtypes = [ 267 | ctypes.c_void_p, 268 | ctypes.c_char_p, 269 | ctypes.c_size_t, 270 | ] 271 | lib.MediaInfo_Open_Buffer_Continue.restype = ctypes.c_size_t 272 | lib.MediaInfo_Open_Buffer_Continue_GoTo_Get.argtypes = [ctypes.c_void_p] 273 | lib.MediaInfo_Open_Buffer_Continue_GoTo_Get.restype = ctypes.c_uint64 274 | lib.MediaInfo_Open_Buffer_Finalize.argtypes = [ctypes.c_void_p] 275 | lib.MediaInfo_Open_Buffer_Finalize.restype = ctypes.c_size_t 276 | lib.MediaInfo_Delete.argtypes = [ctypes.c_void_p] 277 | lib.MediaInfo_Delete.restype = None 278 | lib.MediaInfo_Close.argtypes = [ctypes.c_void_p] 279 | lib.MediaInfo_Close.restype = None 280 | 281 | @staticmethod 282 | def _get_library_paths(os_is_nt: bool) -> tuple[str, ...]: 283 | library_paths: tuple[str, ...] 284 | if os_is_nt: 285 | library_paths = ("MediaInfo.dll",) 286 | elif sys.platform == "darwin": 287 | library_paths = ("libmediainfo.0.dylib", "libmediainfo.dylib") 288 | else: 289 | library_paths = ("libmediainfo.so.0",) 290 | script_dir = os.path.dirname(__file__) 291 | # Look for the library file in the script folder 292 | for library in library_paths: 293 | absolute_library_path = os.path.join(script_dir, library) 294 | if os.path.isfile(absolute_library_path): 295 | # If we find it, don't try any other filename 296 | library_paths = (absolute_library_path,) 297 | break 298 | return library_paths 299 | 300 | @classmethod 301 | def _get_library( 302 | cls, 303 | library_file: str | None = None, 304 | ) -> tuple[Any, Any, str, tuple[int, ...]]: 305 | os_is_nt = os.name in ("nt", "dos", "os2", "ce") 306 | lib_type = ctypes.WinDLL if os_is_nt else ctypes.CDLL # type: ignore[attr-defined] 307 | if library_file is None: 308 | library_paths = cls._get_library_paths(os_is_nt) 309 | else: 310 | library_paths = (library_file,) 311 | exceptions = [] 312 | for library_path in library_paths: 313 | try: 314 | lib = lib_type(library_path) 315 | cls._define_library_prototypes(lib) 316 | # Without a handle, there might be problems when using concurrent threads 317 | # https://github.com/sbraz/pymediainfo/issues/76#issuecomment-574759621 318 | handle = lib.MediaInfo_New() 319 | version = lib.MediaInfo_Option(handle, "Info_Version", "") 320 | match = re.search(r"^MediaInfoLib - v(\S+)", version) 321 | if match: 322 | lib_version_str = match.group(1) 323 | lib_version = tuple(int(_) for _ in lib_version_str.split(".")) 324 | else: 325 | raise RuntimeError("Could not determine library version") 326 | return (lib, handle, lib_version_str, lib_version) 327 | except OSError as exc: 328 | exceptions.append(str(exc)) 329 | raise OSError( 330 | "Failed to load library from {} - {}".format( 331 | ", ".join(library_paths), ", ".join(exceptions) 332 | ) 333 | ) 334 | 335 | @classmethod 336 | def can_parse(cls, library_file: str | None = None) -> bool: 337 | """ 338 | Checks whether media files can be analyzed using libmediainfo. 339 | 340 | :param str library_file: path to the libmediainfo library, this should only be used if 341 | the library cannot be auto-detected. See also :ref:`library_autodetection` which 342 | explains how the library file is detected when this parameter is unset. 343 | :rtype: bool 344 | """ 345 | try: 346 | lib, handle = cls._get_library(library_file)[:2] 347 | lib.MediaInfo_Close(handle) 348 | lib.MediaInfo_Delete(handle) 349 | return True 350 | except Exception: # pylint: disable=broad-except 351 | return False 352 | 353 | # The method may be called with output=, in which case it returns a str 354 | @overload 355 | @classmethod 356 | def parse( 357 | # pylint: disable=too-many-arguments, too-many-locals 358 | # pylint: disable=too-many-branches, too-many-statements 359 | cls, 360 | filename: Any, 361 | *, 362 | library_file: str | None = None, 363 | cover_data: bool = False, 364 | encoding_errors: str = "strict", 365 | parse_speed: float = 0.5, 366 | full: bool = True, 367 | legacy_stream_display: bool = False, 368 | mediainfo_options: dict[str, str] | None = None, 369 | output: str, 370 | buffer_size: int | None = 64 * 1024, 371 | ) -> str: ... 372 | 373 | # Or it may be called with output=None, in which case it returns a MediaInfo object 374 | @overload 375 | @classmethod 376 | def parse( 377 | # pylint: disable=too-many-arguments, too-many-locals 378 | # pylint: disable=too-many-branches, too-many-statements 379 | cls, 380 | filename: Any, 381 | *, 382 | library_file: str | None = None, 383 | cover_data: bool = False, 384 | encoding_errors: str = "strict", 385 | parse_speed: float = 0.5, 386 | full: bool = True, 387 | legacy_stream_display: bool = False, 388 | mediainfo_options: dict[str, str] | None = None, 389 | output: None = None, 390 | buffer_size: int | None = 64 * 1024, 391 | ) -> MediaInfo: ... 392 | 393 | @classmethod 394 | def parse( 395 | # pylint: disable=too-many-arguments, too-many-locals 396 | # pylint: disable=too-many-branches, too-many-statements 397 | cls, 398 | filename: Any, 399 | *, 400 | library_file: str | None = None, 401 | cover_data: bool = False, 402 | encoding_errors: str = "strict", 403 | parse_speed: float = 0.5, 404 | full: bool = True, 405 | legacy_stream_display: bool = False, 406 | mediainfo_options: dict[str, str] | None = None, 407 | output: str | None = None, 408 | buffer_size: int | None = 64 * 1024, 409 | ) -> MediaInfo | str: 410 | """ 411 | Analyze a media file using libmediainfo. 412 | 413 | .. note:: 414 | Because of the way the underlying library works, this method should not 415 | be called simultaneously from multiple threads *with different parameters*. 416 | Doing so will cause inconsistencies or failures by changing 417 | library options that are shared across threads. 418 | 419 | :param filename: path to the media file or file-like object which will be analyzed. 420 | A URL can also be used if libmediainfo was compiled 421 | with CURL support. 422 | :param str library_file: path to the libmediainfo library, this should only be used if 423 | the library cannot be auto-detected. See also :ref:`library_autodetection` which 424 | explains how the library file is detected when this parameter is unset. 425 | :param bool cover_data: whether to retrieve cover data as base64. 426 | :param str encoding_errors: option to pass to :func:`str.encode`'s `errors` 427 | parameter before parsing MediaInfo's XML output. 428 | :param float parse_speed: passed to the library as `ParseSpeed`, 429 | this option takes values between 0 and 1. 430 | A higher value will yield more precise results in some cases 431 | but will also increase parsing time. 432 | :param bool full: display additional tags, including computer-readable values 433 | for sizes and durations, corresponds to the CLI's ``--Full``/``-f`` parameter. 434 | :param bool legacy_stream_display: display additional information about streams. 435 | :param dict mediainfo_options: additional options that will be passed to the 436 | `MediaInfo_Option` function, for example: ``{"Language": "raw"}``. 437 | Do not use this parameter when running the method simultaneously from multiple threads, 438 | it will trigger a reset of all options which will cause inconsistencies or failures. 439 | :param str output: custom output format for MediaInfo, corresponds to the CLI's 440 | ``--Output`` parameter. Setting this causes the method to 441 | return a `str` instead of a :class:`MediaInfo` object. 442 | 443 | Useful values include: 444 | * the empty `str` ``""`` (corresponds to the default 445 | text output, obtained when running ``mediainfo`` with no 446 | additional parameters) 447 | 448 | * ``"XML"`` 449 | 450 | * ``"JSON"`` 451 | 452 | * ``%``-delimited templates (see ``mediainfo --Info-Parameters``) 453 | :param int buffer_size: size of the buffer used to read the file, in bytes. This is only 454 | used when `filename` is a file-like object. 455 | :type filename: str or pathlib.Path or os.PathLike or file-like object. 456 | :rtype: str if `output` is set. 457 | :rtype: :class:`MediaInfo` otherwise. 458 | :raises FileNotFoundError: if passed a non-existent file. 459 | :raises ValueError: if passed a file-like object opened in text mode. 460 | :raises OSError: if the library file could not be loaded. 461 | :raises RuntimeError: if parsing fails, this should not 462 | happen unless libmediainfo itself fails. 463 | 464 | Examples: 465 | >>> pymediainfo.MediaInfo.parse("tests/data/sample.mkv") 466 | 467 | 468 | >>> import json 469 | >>> mi = pymediainfo.MediaInfo.parse("tests/data/sample.mkv", 470 | ... output="JSON") 471 | >>> json.loads(mi)["media"]["track"][0] 472 | {'@type': 'General', 'TextCount': '1', 'FileExtension': 'mkv', 473 | 'FileSize': '5904', … } 474 | 475 | 476 | """ 477 | lib, handle, lib_version_str, lib_version = cls._get_library(library_file) 478 | # The XML option was renamed starting with version 17.10 479 | if lib_version >= (17, 10): 480 | xml_option = "OLDXML" 481 | else: 482 | xml_option = "XML" 483 | # Cover_Data is not extracted by default since version 18.03 484 | # See https://github.com/MediaArea/MediaInfoLib/commit/d8fd88a1 485 | if lib_version >= (18, 3): 486 | lib.MediaInfo_Option(handle, "Cover_Data", "base64" if cover_data else "") 487 | lib.MediaInfo_Option(handle, "CharSet", "UTF-8") 488 | lib.MediaInfo_Option(handle, "Inform", xml_option if output is None else output) 489 | lib.MediaInfo_Option(handle, "Complete", "1" if full else "") 490 | lib.MediaInfo_Option(handle, "ParseSpeed", str(parse_speed)) 491 | lib.MediaInfo_Option(handle, "LegacyStreamDisplay", "1" if legacy_stream_display else "") 492 | if mediainfo_options is not None: 493 | if lib_version < (19, 9): 494 | warnings.warn( 495 | "This version of MediaInfo (v{}) does not support resetting all " 496 | "options to their default values, passing it custom options is not recommended " 497 | "and may result in unpredictable behavior, see " 498 | "https://github.com/MediaArea/MediaInfoLib/issues/1128".format(lib_version_str), 499 | RuntimeWarning, 500 | ) 501 | for option_name, option_value in mediainfo_options.items(): 502 | lib.MediaInfo_Option(handle, option_name, option_value) 503 | try: 504 | filename.seek(0, 2) 505 | file_size = filename.tell() 506 | filename.seek(0) 507 | except AttributeError: # filename is not a file-like object 508 | file_size = None 509 | 510 | if file_size is not None: # We have a file-like object, use the buffer protocol: 511 | # Some file-like objects do not have a mode 512 | if "b" not in getattr(filename, "mode", "b"): 513 | raise ValueError("File should be opened in binary mode") 514 | lib.MediaInfo_Open_Buffer_Init(handle, file_size, 0) 515 | while True: 516 | buffer = filename.read(buffer_size) 517 | if buffer: 518 | # https://github.com/MediaArea/MediaInfoLib/blob/v20.09/Source/MediaInfo/File__Analyze.h#L1429 519 | # 4th bit = finished 520 | if lib.MediaInfo_Open_Buffer_Continue(handle, buffer, len(buffer)) & 0x08: 521 | break 522 | # Ask MediaInfo if we need to seek 523 | seek = lib.MediaInfo_Open_Buffer_Continue_GoTo_Get(handle) 524 | # https://github.com/MediaArea/MediaInfoLib/blob/v20.09/Source/MediaInfoDLL/MediaInfoJNI.cpp#L127 525 | if seek != ctypes.c_uint64(-1).value: 526 | filename.seek(seek) 527 | # Inform MediaInfo we have sought 528 | lib.MediaInfo_Open_Buffer_Init(handle, file_size, filename.tell()) 529 | else: 530 | break 531 | lib.MediaInfo_Open_Buffer_Finalize(handle) 532 | else: # We have a filename, simply pass it: 533 | filename = cls._normalize_filename(filename) 534 | # If an error occured 535 | if lib.MediaInfo_Open(handle, filename) == 0: 536 | lib.MediaInfo_Close(handle) 537 | lib.MediaInfo_Delete(handle) 538 | # If filename doesn't look like a URL and doesn't exist 539 | if "://" not in filename and not os.path.exists(filename): 540 | raise FileNotFoundError(filename) 541 | # We ran into another kind of error 542 | raise RuntimeError( 543 | "An error occured while opening {}" " with libmediainfo".format(filename) 544 | ) 545 | info: str = lib.MediaInfo_Inform(handle, 0) 546 | # Reset all options to their defaults so that they aren't 547 | # retained when the parse method is called several times 548 | # https://github.com/MediaArea/MediaInfoLib/issues/1128 549 | # Do not call it when it is not required because it breaks threads 550 | # https://github.com/sbraz/pymediainfo/issues/76#issuecomment-575245093 551 | if mediainfo_options is not None and lib_version >= (19, 9): 552 | lib.MediaInfo_Option(handle, "Reset", "") 553 | # Delete the handle 554 | lib.MediaInfo_Close(handle) 555 | lib.MediaInfo_Delete(handle) 556 | if output is None: 557 | return cls(info, encoding_errors) 558 | return info 559 | 560 | def to_data(self) -> dict[str, Any]: 561 | """ 562 | Returns a dict representation of the object's :py:class:`Tracks `. 563 | 564 | :rtype: dict 565 | """ 566 | return {"tracks": [_.to_data() for _ in self.tracks]} 567 | 568 | def to_json(self) -> str: 569 | """ 570 | Returns a JSON representation of the object's :py:class:`Tracks `. 571 | 572 | :rtype: str 573 | """ 574 | return json.dumps(self.to_data()) 575 | -------------------------------------------------------------------------------- /src/pymediainfo/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/src/pymediainfo/py.typed -------------------------------------------------------------------------------- /tests/data/aac_he_v2.aac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/aac_he_v2.aac -------------------------------------------------------------------------------- /tests/data/accentué.txt: -------------------------------------------------------------------------------- 1 | This is a test file 2 | -------------------------------------------------------------------------------- /tests/data/empty.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/empty.gif -------------------------------------------------------------------------------- /tests/data/invalid.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 260 7 | 1 8 | General 9 | General 10 | 0 11 | 1 12 | 1 13 | 1 14 | Digital Video 15 | Digital Video 16 | DV 17 | English 18 | PCM 19 | PCM 20 | PCM 21 | English 22 | TimeCode 23 | TimeCode 24 | English 25 | credits.mov 26 | credits.mov 27 | mov 28 | MPEG-4 29 | MPEG-4 30 | mp4 m4v m4a m4p 3gpp 3gp 3gpp2 3g2 k3g jpm jpx mqv ismv isma f4v 31 | QuickTime 32 | qt 33 | http://www.apple.com/quicktime/download/standalone.html 34 | MPEG-4 35 | MPEG-4 36 | mp4 m4v m4a m4p 3gpp 3gp 3gpp2 3g2 k3g jpm jpx mqv ismv isma f4v 37 | 712816548 38 | 680 MiB 39 | 680 MiB 40 | 680 MiB 41 | 680 MiB 42 | 679.8 MiB 43 | 593474 44 | 9mn 53s 45 | 9mn 53s 474ms 46 | 9mn 53s 47 | 00:09:53.474 48 | 9608731 49 | 9 609 Kbps 50 | 194196 51 | 190 KiB (0%) 52 | 190 KiB 53 | 190 KiB 54 | 190 KiB 55 | 189.6 KiB 56 | 190 KiB (0%) 57 | 0.00027 58 | UTC 2010-04-12 14:58:21 59 | UTC 2010-04-12 15:00:37 60 | UTC 2010-04-15 14:40:32 61 | 2010-04-15 09:40:32 62 | Apple QuickTime 63 | Apple QuickTime 64 | Apple QuickTime 65 | <>00;05;34;23 66 | 67 | 68 | 69 | 70 | 148 71 | 1 72 | Video 73 | Video 74 | 0 75 | 1 76 | 1 77 | Digital Video 78 | dvc 79 | http://www.apple.com/quicktime/download/standalone.html 80 | DV 81 | DV 82 | DV 83 | Apple QuickTime DV (DVCPRO NTSC) 84 | http://www.apple.com/quicktime/download/standalone.html 85 | dvc 86 | 258558 87 | 4mn 18s 88 | 4mn 18s 558ms 89 | 4mn 18s 90 | 00:04:18.558 91 | CBR 92 | Constant 93 | 20874240 94 | 20.9 Mbps 95 | 720 96 | 720 pixels 97 | 480 98 | 480 pixels 99 | 0.909 100 | 0.889 101 | 1.363 102 | 4:3 103 | 1.333 104 | 4:3 105 | 0.000 106 | VFR 107 | Variable 108 | 21.744 109 | 21.744 fps 110 | 0.111 111 | 0.111 fps 112 | 29.970 113 | 29.970 fps 114 | 29.970 115 | 29.970 fps 116 | 5622 117 | NTSC 118 | 4:1:1 119 | Interlaced 120 | Interlaced 121 | Interlaced 122 | Interlaced 123 | 2.778 124 | 334768 125 | 5mn 34s 126 | 5mn 34s 768ms 127 | 5mn 34s 128 | 00:05:34.768 129 | DropFrame=Yes / 24HourMax=No / IsVisual=No 130 | 0 131 | 674640000 132 | 643 MiB (95%) 133 | 643 MiB 134 | 643 MiB 135 | 643 MiB 136 | 643.4 MiB 137 | 643 MiB (95%) 138 | 0.94644 139 | en 140 | English 141 | UTC 2010-04-12 14:58:21 142 | UTC 2010-04-12 15:00:37 143 | 144 | 145 | 146 | 147 | 129 148 | 1 149 | Audio 150 | Audio 151 | 0 152 | 2 153 | 2 154 | PCM 155 | Little / Signed 156 | Little 157 | Signed 158 | sowt 159 | http://www.apple.com/quicktime/download/standalone.html 160 | PCM 161 | PCM 162 | PCM 163 | http://www.apple.com/quicktime/download/standalone.html 164 | sowt 165 | Little / Signed 166 | Little 167 | Signed 168 | 593474 169 | 9mn 53s 170 | 9mn 53s 474ms 171 | 9mn 53s 172 | 00:09:53.474 173 | CBR 174 | Constant 175 | 512000 176 | 512 Kbps 177 | 1 178 | 1 channel 179 | 32000 180 | 32.0 KHz 181 | 18991168 182 | 16 183 | 16 bits 184 | 37982352 185 | 36.2 MiB (5%) 186 | 36 MiB 187 | 36 MiB 188 | 36.2 MiB 189 | 36.22 MiB 190 | 36.2 MiB (5%) 191 | 0.05328 192 | en 193 | English 194 | UTC 2010-04-12 14:58:21 195 | UTC 2010-04-12 15:00:37 196 | 197 | 198 | 199 | 200 | 51 201 | 1 202 | Menu 203 | Menu 204 | 0 205 | 3 206 | 3 207 | TimeCode 208 | en 209 | English 210 | UTC 2010-04-12 15:00:37 211 | UTC 2010-04-12 15:00:37 212 | 213 | 214 | 215 | 216 | 217 | -------------------------------------------------------------------------------- /tests/data/issue100.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 331 6 | 1 7 | General 8 | General 9 | 0 10 | 1 11 | 1 12 | 2 13 | AVC 14 | AVC 15 | AVC 16 | AAC LC 17 | AAC LC 18 | AAC LC 19 | RTP / RTP 20 | RTP / RTP 21 | RTP / RTP 22 | English / English 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/data/issue55.flv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/issue55.flv -------------------------------------------------------------------------------- /tests/data/mp3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/mp3.mp3 -------------------------------------------------------------------------------- /tests/data/mp4-with-audio.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/mp4-with-audio.mp4 -------------------------------------------------------------------------------- /tests/data/mpeg4.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/mpeg4.mp4 -------------------------------------------------------------------------------- /tests/data/other_track.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | test.mxf 6 | 7 | 8 | 2 9 | MPEG Video 10 | Version 2 11 | 12 | 13 | 3 14 | PCM 15 | 16 | 17 | 1-Material 18 | Time code 19 | MXF TC 20 | 25.000 FPS 21 | 00:00:00:00 22 | Material Package 23 | Yes 24 | 25 | 26 | 1-Source 27 | Time code 28 | MXF TC 29 | 25.000 FPS 30 | 00:00:00:00 31 | Source Package 32 | Yes 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /tests/data/sample.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/sample.mkv -------------------------------------------------------------------------------- /tests/data/sample.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/sample.mp4 -------------------------------------------------------------------------------- /tests/data/sample.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 260 6 | 1 7 | General 8 | General 9 | 0 10 | 1 11 | 1 12 | 1 13 | Digital Video 14 | Digital Video (DVCPRO HD) 15 | DV 16 | English 17 | PCM 18 | PCM 19 | PCM 20 | English 21 | TimeCode 22 | TimeCode 23 | English 24 | Downloads/source.mov 25 | Downloads 26 | source 27 | mov 28 | MPEG-4 29 | MPEG-4 30 | mp4 m4v m4a m4p 3gpp 3gp 3gpp2 3g2 k3g jpm jpx mqv ismv isma f4v 31 | QuickTime 32 | qt 33 | http://www.apple.com/quicktime/download/standalone.html 34 | MPEG-4 35 | MPEG-4 36 | mp4 m4v m4a m4p 3gpp 3gp 3gpp2 3g2 k3g jpm jpx mqv ismv isma f4v 37 | 365132611 38 | 348 MiB 39 | 348 MiB 40 | 348 MiB 41 | 348 MiB 42 | 348.2 MiB 43 | 61394 44 | 1mn 1s 45 | 1mn 1s 394ms 46 | 1mn 1s 47 | 00:01:01.394 48 | 47578930 49 | 47.6 Mbps 50 | 64835 51 | 63.3 KiB (0%) 52 | 63 KiB 53 | 63 KiB 54 | 63.3 KiB 55 | 63.32 KiB 56 | 63.3 KiB (0%) 57 | 0.00018 58 | UTC 2010-03-22 14:47:44 59 | UTC 2010-03-22 14:48:21 60 | UTC 2010-03-22 18:56:55 61 | 2010-03-22 13:56:55 62 | Apple QuickTime 63 | Apple QuickTime 64 | Apple QuickTime 65 | 1C8E7037-D348-4981-9CD3-D60AFEE7FC1C 66 | 67 | 68 | 148 69 | 1 70 | Video 71 | Video 72 | 0 73 | 1 74 | 1 75 | Digital Video 76 | dvhp 77 | DVCPRO HD 78 | http://www.apple.com/quicktime/download/standalone.html 79 | DV 80 | DV 81 | dvhp 82 | 61394 83 | 1mn 1s 84 | 1mn 1s 394ms 85 | 1mn 1s 86 | 00:01:01.394 87 | CBR 88 | Constant 89 | 46033920 90 | 46.0 Mbps 91 | 960 92 | 960 pixels 93 | 720 94 | 720 pixels 95 | 1.333 96 | 1.778 97 | 16:9 98 | 0.000 / 0.000 99 | CFR 100 | Constant 101 | 23.976 102 | 23.976 fps 103 | 1472 104 | NTSC 105 | 4:1:1 106 | Interlaced 107 | Interlaced 108 | Interlaced 109 | Interlaced 110 | 2.778 111 | 3600000 112 | 1h 0mn 113 | 1h 0mn 0s 0ms 114 | 1h 0mn 115 | 01:00:00.000 116 | DropFrame=No / 24HourMax=No / IsVisual=No 117 | 0 118 | 353280000 119 | 337 MiB (97%) 120 | 337 MiB 121 | 337 MiB 122 | 337 MiB 123 | 336.9 MiB 124 | 337 MiB (97%) 125 | 0.96754 126 | en 127 | English 128 | UTC 2010-03-22 14:47:44 129 | UTC 2010-03-22 14:48:21 130 | 131 | 132 | 129 133 | 1 134 | Audio 135 | Audio 136 | 0 137 | 2 138 | 2 139 | PCM 140 | Little / Signed 141 | Little 142 | Signed 143 | sowt 144 | http://www.apple.com/quicktime/download/standalone.html 145 | PCM 146 | PCM 147 | PCM 148 | http://www.apple.com/quicktime/download/standalone.html 149 | sowt 150 | Little / Signed 151 | Little 152 | Signed 153 | 61394 154 | 1mn 1s 155 | 1mn 1s 394ms 156 | 1mn 1s 157 | 00:01:01.394 158 | CBR 159 | Constant 160 | 1536000 161 | 1 536 Kbps 162 | 2 163 | 2 channels 164 | 48000 165 | 48.0 KHz 166 | 2946912 167 | 16 168 | 16 bits 169 | 11787776 170 | 11.2 MiB (3%) 171 | 11 MiB 172 | 11 MiB 173 | 11.2 MiB 174 | 11.24 MiB 175 | 11.2 MiB (3%) 176 | 0.03228 177 | en 178 | English 179 | UTC 2010-03-22 14:47:44 180 | UTC 2010-03-22 14:48:21 181 | 182 | 183 | 51 184 | 1 185 | Menu 186 | Menu 187 | 0 188 | 3 189 | 3 190 | TimeCode 191 | en 192 | English 193 | UTC 2010-03-22 14:48:21 194 | UTC 2010-03-22 14:48:21 195 | 196 | 197 | 198 | -------------------------------------------------------------------------------- /tests/data/sample_with_cover.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/sample_with_cover.mp3 -------------------------------------------------------------------------------- /tests/data/vbr_requires_parsespeed_1.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbraz/pymediainfo/daf3596e33686c17639d4bd1a4f560983f24ea35/tests/data/vbr_requires_parsespeed_1.mp4 -------------------------------------------------------------------------------- /tests/test_pymediainfo.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-module-docstring, missing-class-docstring, missing-function-docstring, 2 | # pylint: disable=protected-access 3 | 4 | import functools 5 | import http.server 6 | import json 7 | import os 8 | import pathlib 9 | import pickle 10 | import sys 11 | import tempfile 12 | import threading 13 | import unittest 14 | import xml 15 | 16 | import pytest 17 | 18 | from pymediainfo import MediaInfo 19 | 20 | data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") 21 | test_media_files = [ 22 | "sample.mkv", 23 | "sample.mp4", 24 | "sample_with_cover.mp3", 25 | "mpeg4.mp4", 26 | "mp3.mp3", 27 | "mp4-with-audio.mp4", 28 | ] 29 | 30 | 31 | def _get_library_version() -> tuple[str, tuple[int, ...]]: 32 | lib, handle, lib_version_str, lib_version = MediaInfo._get_library() 33 | lib.MediaInfo_Close(handle) 34 | lib.MediaInfo_Delete(handle) 35 | return lib_version_str, lib_version 36 | 37 | 38 | class MediaInfoTest(unittest.TestCase): 39 | def setUp(self) -> None: 40 | with open(os.path.join(data_dir, "sample.xml"), "r", encoding="utf-8") as f: 41 | self.xml_data = f.read() 42 | self.media_info = MediaInfo(self.xml_data) 43 | 44 | def test_populate_tracks(self) -> None: 45 | self.assertEqual(4, len(self.media_info.tracks)) 46 | 47 | def test_valid_video_track(self) -> None: 48 | for track in self.media_info.tracks: 49 | if track.track_type == "Video": 50 | self.assertEqual("DV", track.codec) 51 | self.assertEqual("Interlaced", track.scan_type) 52 | break 53 | 54 | def test_track_integer_attributes(self) -> None: 55 | for track in self.media_info.tracks: 56 | if track.track_type == "Audio": 57 | self.assertTrue(isinstance(track.duration, int)) 58 | self.assertTrue(isinstance(track.bit_rate, int)) 59 | self.assertTrue(isinstance(track.sampling_rate, int)) 60 | break 61 | 62 | def test_track_other_attributes(self) -> None: 63 | general_tracks = [ 64 | track for track in self.media_info.tracks if track.track_type == "General" 65 | ] 66 | general_track = general_tracks[0] 67 | self.assertEqual(5, len(general_track.other_file_size)) 68 | self.assertEqual( 69 | ["1mn 1s", "1mn 1s 394ms", "1mn 1s", "00:01:01.394"], general_track.other_duration 70 | ) 71 | 72 | def test_track_existing_other_attributes(self) -> None: 73 | with open(os.path.join(data_dir, "issue100.xml"), encoding="utf-8") as f: 74 | media_info = MediaInfo(f.read()) 75 | general_tracks = [track for track in media_info.tracks if track.track_type == "General"] 76 | general_track = general_tracks[0] 77 | self.assertEqual(general_track.other_format_list, "RTP / RTP") 78 | 79 | def test_load_mediainfo_from_string(self) -> None: 80 | self.assertEqual(4, len(self.media_info.tracks)) 81 | 82 | def test_getting_attribute_that_doesnot_exist(self) -> None: 83 | self.assertTrue(self.media_info.tracks[0].does_not_exist is None) 84 | 85 | 86 | class MediaInfoInvalidXMLTest(unittest.TestCase): 87 | def setUp(self) -> None: 88 | with open(os.path.join(data_dir, "invalid.xml"), "r", encoding="utf-8") as f: 89 | self.xml_data = f.read() 90 | 91 | def test_parse_invalid_xml(self) -> None: 92 | self.assertRaises(xml.etree.ElementTree.ParseError, MediaInfo, self.xml_data) 93 | 94 | 95 | class MediaInfoLibraryTest(unittest.TestCase): 96 | def setUp(self) -> None: 97 | self.media_info = MediaInfo.parse(os.path.join(data_dir, "sample.mp4")) 98 | self.non_full_mi = MediaInfo.parse(os.path.join(data_dir, "sample.mp4"), full=False) 99 | 100 | def test_can_parse_true(self) -> None: 101 | self.assertTrue(MediaInfo.can_parse()) 102 | 103 | def test_track_count(self) -> None: 104 | self.assertEqual(len(self.media_info.tracks), 3) 105 | 106 | def test_track_types(self) -> None: 107 | self.assertEqual(self.media_info.tracks[1].track_type, "Video") 108 | self.assertEqual(self.media_info.tracks[2].track_type, "Audio") 109 | 110 | def test_track_details(self) -> None: 111 | self.assertEqual(self.media_info.tracks[1].format, "AVC") 112 | self.assertEqual(self.media_info.tracks[2].format, "AAC") 113 | self.assertEqual(self.media_info.tracks[1].duration, 958) 114 | self.assertEqual(self.media_info.tracks[2].duration, 980) 115 | 116 | def test_full_option(self) -> None: 117 | self.assertEqual(self.media_info.tracks[0].footersize, "59") 118 | self.assertEqual(self.non_full_mi.tracks[0].footersize, None) 119 | 120 | def test_raises_on_nonexistent_library(self) -> None: 121 | with tempfile.TemporaryDirectory() as tmp_dir: 122 | nonexistent_library = os.path.join(tmp_dir, "nonexistent-libmediainfo.so") 123 | with pytest.raises(OSError) as exc: 124 | MediaInfo.parse( 125 | os.path.join(data_dir, "sample.mp4"), library_file=nonexistent_library 126 | ) 127 | assert rf"Failed to load library from {nonexistent_library}" in str(exc.value) 128 | 129 | 130 | class MediaInfoFileLikeTest(unittest.TestCase): 131 | def test_can_parse(self) -> None: 132 | with open(os.path.join(data_dir, "sample.mp4"), "rb") as f: 133 | MediaInfo.parse(f) 134 | 135 | def test_raises_on_text_mode_even_with_text(self) -> None: 136 | with open(os.path.join(data_dir, "sample.xml"), encoding="utf-8") as f: 137 | self.assertRaises(ValueError, MediaInfo.parse, f) 138 | 139 | def test_raises_on_text_mode(self) -> None: 140 | with open(os.path.join(data_dir, "sample.mkv"), encoding="utf-8") as f: 141 | self.assertRaises(ValueError, MediaInfo.parse, f) 142 | 143 | 144 | class MediaInfoUnicodeXMLTest(unittest.TestCase): 145 | def setUp(self) -> None: 146 | self.media_info = MediaInfo.parse(os.path.join(data_dir, "sample.mkv")) 147 | 148 | def test_parse_file_with_unicode_tags(self) -> None: 149 | self.assertEqual( 150 | self.media_info.tracks[0].title, 151 | "Dès Noël où un zéphyr haï me vêt de glaçons " 152 | "würmiens je dîne d’exquis rôtis de bœuf au kir à " 153 | "l’aÿ d’âge mûr & cætera !", 154 | ) 155 | 156 | 157 | class MediaInfoUnicodeFileNameTest(unittest.TestCase): 158 | def setUp(self) -> None: 159 | self.media_info = MediaInfo.parse(os.path.join(data_dir, "accentué.txt")) 160 | 161 | def test_parse_unicode_file(self) -> None: 162 | self.assertEqual(len(self.media_info.tracks), 1) 163 | 164 | 165 | @pytest.mark.skipif( 166 | sys.version_info < (3, 7), 167 | reason="SimpleHTTPRequestHandler's 'directory' argument was added in Python 3.7", 168 | ) 169 | class MediaInfoURLTest(unittest.TestCase): 170 | def setUp(self) -> None: 171 | HandlerClass = functools.partial( # pylint: disable=invalid-name 172 | http.server.SimpleHTTPRequestHandler, 173 | directory=data_dir, 174 | ) 175 | # Pick a random port so that parallel tests (e.g. via 'tox -p') do not clash 176 | self.httpd = http.server.HTTPServer(("", 0), HandlerClass) 177 | port = self.httpd.socket.getsockname()[1] 178 | self.url = f"http://127.0.0.1:{port}/sample.mkv" 179 | threading.Thread(target=self.httpd.serve_forever).start() 180 | 181 | def tearDown(self) -> None: 182 | self.httpd.shutdown() 183 | self.httpd.server_close() 184 | 185 | def test_parse_url(self) -> None: 186 | media_info = MediaInfo.parse(self.url) 187 | self.assertEqual(len(media_info.tracks), 3) 188 | 189 | 190 | class MediaInfoPathlibTest(unittest.TestCase): 191 | def test_parse_pathlib_path(self) -> None: 192 | path = pathlib.Path(data_dir) / "sample.mp4" 193 | media_info = MediaInfo.parse(path) 194 | self.assertEqual(len(media_info.tracks), 3) 195 | 196 | def test_parse_non_existent_path_pathlib(self) -> None: 197 | path = pathlib.Path(data_dir) / "this file does not exist" 198 | self.assertRaises(FileNotFoundError, MediaInfo.parse, path) 199 | 200 | 201 | class MediaInfoFilenameTypesTest(unittest.TestCase): 202 | def test_normalize_filename_str(self) -> None: 203 | path = os.path.join(data_dir, "test.txt") 204 | filename = MediaInfo._normalize_filename(path) 205 | self.assertEqual(filename, path) 206 | 207 | def test_normalize_filename_pathlib(self) -> None: 208 | path = pathlib.Path(data_dir, "test.txt") 209 | filename = MediaInfo._normalize_filename(path) 210 | self.assertEqual(filename, os.path.join(data_dir, "test.txt")) 211 | 212 | def test_normalize_filename_pathlike(self) -> None: 213 | class PathLikeObject(os.PathLike[str]): 214 | # pylint: disable=too-few-public-methods 215 | def __fspath__(self) -> str: 216 | return os.path.join(data_dir, "test.txt") 217 | 218 | path = PathLikeObject() 219 | filename = MediaInfo._normalize_filename(path) 220 | self.assertEqual(filename, os.path.join(data_dir, "test.txt")) 221 | 222 | def test_normalize_filename_url(self) -> None: 223 | filename = MediaInfo._normalize_filename("https://localhost") 224 | self.assertEqual(filename, "https://localhost") 225 | 226 | 227 | class MediaInfoTestParseNonExistentFile(unittest.TestCase): 228 | def test_parse_non_existent_path(self) -> None: 229 | path = os.path.join(data_dir, "this file does not exist") 230 | self.assertRaises(FileNotFoundError, MediaInfo.parse, path) 231 | 232 | 233 | class MediaInfoCoverDataTest(unittest.TestCase): 234 | def setUp(self) -> None: 235 | self.cover_mi = MediaInfo.parse( 236 | os.path.join(data_dir, "sample_with_cover.mp3"), cover_data=True 237 | ) 238 | self.no_cover_mi = MediaInfo.parse(os.path.join(data_dir, "sample_with_cover.mp3")) 239 | 240 | def test_parse_cover_data(self) -> None: 241 | self.assertEqual( 242 | self.cover_mi.tracks[0].cover_data, 243 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAAAAA" 244 | "AAAQCEeRdzAAAADUlEQVR4nGP4x8DwHwAE/AH+QSRCQgAAAABJRU5ErkJggg==", 245 | ) 246 | 247 | def test_parse_no_cover_data(self) -> None: 248 | lib_version_str, lib_version = _get_library_version() 249 | if lib_version < (18, 3): 250 | pytest.skip( 251 | "The Cover_Data option is not supported by this library version " 252 | "(v{} detected, v18.03 required)".format(lib_version_str) 253 | ) 254 | self.assertEqual(self.no_cover_mi.tracks[0].cover_data, None) 255 | 256 | 257 | class MediaInfoTrackParsingTest(unittest.TestCase): 258 | def test_track_parsing(self) -> None: 259 | media_info = MediaInfo.parse(os.path.join(data_dir, "issue55.flv")) 260 | self.assertEqual(len(media_info.tracks), 2) 261 | 262 | 263 | class MediaInfoRuntimeErrorTest(unittest.TestCase): 264 | def test_parse_invalid_url(self) -> None: 265 | # This is the easiest way to cause a parsing error 266 | # since non-existent files return a different exception 267 | self.assertRaises(RuntimeError, MediaInfo.parse, "unsupportedscheme://") 268 | 269 | 270 | class MediaInfoSlowParseTest(unittest.TestCase): 271 | def setUp(self) -> None: 272 | self.media_info = MediaInfo.parse( 273 | os.path.join(data_dir, "vbr_requires_parsespeed_1.mp4"), parse_speed=1 274 | ) 275 | 276 | def test_slow_parse_speed(self) -> None: 277 | self.assertEqual(self.media_info.tracks[2].stream_size, "3353 / 45") 278 | 279 | 280 | class MediaInfoEqTest(unittest.TestCase): 281 | def setUp(self) -> None: 282 | self.mp3_mi = MediaInfo.parse(os.path.join(data_dir, "sample_with_cover.mp3")) 283 | self.mp3_other_mi = MediaInfo.parse(os.path.join(data_dir, "sample_with_cover.mp3")) 284 | self.mp4_mi = MediaInfo.parse(os.path.join(data_dir, "sample.mp4")) 285 | 286 | def test_eq(self) -> None: 287 | self.assertEqual(self.mp3_mi.tracks[0], self.mp3_other_mi.tracks[0]) 288 | self.assertEqual(self.mp3_mi, self.mp3_other_mi) 289 | self.assertNotEqual(self.mp3_mi.tracks[0], self.mp4_mi.tracks[0]) 290 | self.assertNotEqual(self.mp3_mi, self.mp4_mi) 291 | 292 | def test_pickle_unpickle(self) -> None: 293 | pickled_track = pickle.dumps(self.mp4_mi.tracks[0]) 294 | self.assertEqual(self.mp4_mi.tracks[0], pickle.loads(pickled_track)) 295 | pickled_mi = pickle.dumps(self.mp4_mi) 296 | self.assertEqual(self.mp4_mi, pickle.loads(pickled_mi)) 297 | 298 | 299 | class MediaInfoLegacyStreamDisplayTest(unittest.TestCase): 300 | def setUp(self) -> None: 301 | self.media_info = MediaInfo.parse(os.path.join(data_dir, "aac_he_v2.aac")) 302 | self.legacy_mi = MediaInfo.parse( 303 | os.path.join(data_dir, "aac_he_v2.aac"), legacy_stream_display=True 304 | ) 305 | 306 | def test_legacy_stream_display(self) -> None: 307 | self.assertEqual(self.media_info.tracks[1].channel_s, 2) 308 | self.assertEqual(self.legacy_mi.tracks[1].channel_s, "2 / 1 / 1") 309 | 310 | 311 | class MediaInfoOptionsTest(unittest.TestCase): 312 | def setUp(self) -> None: 313 | lib_version_str, lib_version = _get_library_version() 314 | if lib_version < (19, 9): 315 | pytest.skip( 316 | "The Reset option is not supported by this library version " 317 | "(v{} detected, v19.09 required)".format(lib_version_str) 318 | ) 319 | self.raw_language_mi = MediaInfo.parse( 320 | os.path.join(data_dir, "sample.mkv"), 321 | mediainfo_options={"Language": "raw"}, 322 | ) 323 | # Parsing the file without the custom options afterwards 324 | # allows us to check that the "Reset" option worked 325 | # https://github.com/MediaArea/MediaInfoLib/issues/1128 326 | self.normal_mi = MediaInfo.parse( 327 | os.path.join(data_dir, "sample.mkv"), 328 | ) 329 | 330 | def test_mediainfo_options(self) -> None: 331 | self.assertEqual(self.normal_mi.tracks[1].other_language[0], "English") 332 | self.assertEqual(self.raw_language_mi.tracks[1].language, "en") 333 | 334 | 335 | # Unittests can't be parametrized 336 | # https://github.com/pytest-dev/pytest/issues/541 337 | @pytest.mark.parametrize("test_file", test_media_files) 338 | def test_thread_safety(test_file: str) -> None: 339 | lib_version_str, lib_version = _get_library_version() 340 | if lib_version < (20, 3): 341 | pytest.skip( 342 | "This version of the library is not thread-safe " 343 | "(v{} detected, v20.03 required)".format(lib_version_str) 344 | ) 345 | expected_result = MediaInfo.parse(os.path.join(data_dir, test_file)) 346 | results = [] 347 | lock = threading.Lock() 348 | 349 | def target() -> None: 350 | try: 351 | result = MediaInfo.parse(os.path.join(data_dir, test_file)) 352 | with lock: 353 | results.append(result) 354 | except Exception: # pylint: disable=broad-except 355 | pass 356 | 357 | threads = [] 358 | thread_count = 100 359 | for _ in range(thread_count): 360 | thread = threading.Thread(target=target) 361 | thread.start() 362 | threads.append(thread) 363 | for thread in threads: 364 | thread.join() 365 | # Each thread should have produced a result 366 | assert len(results) == thread_count 367 | for res in results: 368 | # Test dicts first because they will show a diff 369 | # in case they don't match 370 | assert res.to_data() == expected_result.to_data() 371 | assert res == expected_result 372 | 373 | 374 | @pytest.mark.parametrize("test_file", test_media_files) 375 | def test_filelike_returns_the_same(test_file: str) -> None: 376 | filename = os.path.join(data_dir, test_file) 377 | mi_from_filename = MediaInfo.parse(filename) 378 | with open(filename, "rb") as f: 379 | mi_from_file = MediaInfo.parse(f) 380 | assert len(mi_from_file.tracks) == len(mi_from_filename.tracks) 381 | for track_from_file, track_from_filename in zip(mi_from_file.tracks, mi_from_filename.tracks): 382 | # The General track will differ, typically not giving the file name 383 | if track_from_file.track_type != "General": 384 | # Test dicts first because they will produce a diff 385 | assert track_from_file.to_data() == track_from_filename.to_data() 386 | assert track_from_file == track_from_filename 387 | 388 | 389 | class MediaInfoOutputTest(unittest.TestCase): 390 | def test_text_output(self) -> None: 391 | media_info = MediaInfo.parse(os.path.join(data_dir, "sample.mp4"), output="") 392 | self.assertRegex(media_info, r"Stream size\s+: 373836\b") 393 | 394 | def test_json_output(self) -> None: 395 | lib_version_str, lib_version = _get_library_version() 396 | if lib_version < (18, 3): 397 | pytest.skip( 398 | "This version of the library does not support JSON output " 399 | "(v{} detected, v18.03 required)".format(lib_version_str) 400 | ) 401 | media_info = MediaInfo.parse(os.path.join(data_dir, "sample.mp4"), output="JSON") 402 | parsed = json.loads(media_info) 403 | self.assertEqual(parsed["media"]["track"][0]["FileSize"], "404567") 404 | 405 | def test_parameter_output(self) -> None: 406 | media_info = MediaInfo.parse( 407 | os.path.join(data_dir, "sample.mp4"), output="General;%FileSize%" 408 | ) 409 | self.assertEqual(media_info, "404567") 410 | 411 | 412 | class MediaInfoTrackShortcutsTests(unittest.TestCase): 413 | def setUp(self) -> None: 414 | self.mi_audio = MediaInfo.parse(os.path.join(data_dir, "sample.mp4")) 415 | self.mi_text = MediaInfo.parse(os.path.join(data_dir, "sample.mkv")) 416 | self.mi_image = MediaInfo.parse(os.path.join(data_dir, "empty.gif")) 417 | with open(os.path.join(data_dir, "other_track.xml"), encoding="utf-8") as f: 418 | self.mi_other = MediaInfo(f.read()) 419 | 420 | def test_empty_list(self) -> None: 421 | self.assertEqual(self.mi_audio.text_tracks, []) 422 | 423 | def test_general_tracks(self) -> None: 424 | self.assertEqual(len(self.mi_audio.general_tracks), 1) 425 | self.assertIsNotNone(self.mi_audio.general_tracks[0].file_name) 426 | 427 | def test_video_tracks(self) -> None: 428 | self.assertEqual(len(self.mi_audio.video_tracks), 1) 429 | self.assertIsNotNone(self.mi_audio.video_tracks[0].display_aspect_ratio) 430 | 431 | def test_audio_tracks(self) -> None: 432 | self.assertEqual(len(self.mi_audio.audio_tracks), 1) 433 | self.assertIsNotNone(self.mi_audio.audio_tracks[0].sampling_rate) 434 | 435 | def test_text_tracks(self) -> None: 436 | self.assertEqual(len(self.mi_text.text_tracks), 1) 437 | self.assertEqual(self.mi_text.text_tracks[0].kind_of_stream, "Text") 438 | 439 | def test_other_tracks(self) -> None: 440 | self.assertEqual(len(self.mi_other.other_tracks), 2) 441 | self.assertEqual(self.mi_other.other_tracks[0].type, "Time code") 442 | 443 | def test_image_tracks(self) -> None: 444 | self.assertEqual(len(self.mi_image.image_tracks), 1) 445 | self.assertEqual(self.mi_image.image_tracks[0].width, 1) 446 | 447 | def test_menu_tracks(self) -> None: 448 | self.assertEqual(len(self.mi_text.menu_tracks), 1) 449 | self.assertEqual(self.mi_text.menu_tracks[0].kind_of_stream, "Menu") 450 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py39 4 | py310 5 | py311 6 | py312 7 | py313 8 | pypy3 9 | black 10 | flake8 11 | isort 12 | mypy 13 | pylint 14 | 15 | [testenv] 16 | deps = 17 | pytest 18 | pytest-xdist 19 | setuptools_scm 20 | commands = 21 | pytest {posargs:-n auto} 22 | 23 | [testenv:docs] 24 | deps = 25 | alabaster 26 | myst-parser 27 | setuptools_scm 28 | sphinx 29 | commands = 30 | sphinx-build -W --keep-going --color -b html docs docs/_build 31 | sphinx-build -W --keep-going --color -b linkcheck docs docs/_build 32 | 33 | [testenv:black] 34 | deps = 35 | black 36 | commands = black --line-length 100 --check --diff src tests scripts 37 | 38 | [testenv:flake8] 39 | deps = flake8 40 | commands = flake8 --max-line-length 100 src tests 41 | 42 | [testenv:isort] 43 | deps = isort 44 | commands = isort --check src tests scripts 45 | 46 | [testenv:pylint] 47 | deps = 48 | pylint 49 | pytest 50 | commands = pylint src/pymediainfo/ tests/test_pymediainfo.py 51 | 52 | [testenv:mypy] 53 | deps = 54 | mypy 55 | pytest 56 | commands = 57 | mypy --strict src tests 58 | --------------------------------------------------------------------------------