diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 8aad36e..9010a75 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -12,6 +12,7 @@ jobs: CIBW_SKIP: "*p36-* *p37-*" CIBW_ARCHS: auto64 CIBW_ARCHS_MACOS: "x86_64 arm64" + MACOSX_DEPLOYMENT_TARGET: "10.14" steps: - uses: actions/checkout@v3 - name: Set up Python diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b11b7..a5a90d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ # NEXT - [!125](https://github.com/supermihi/pytaglib/pull/125): stop building wheels for out-of-support Python versions 3.6 and 3.7 +- [!123](https://github.com/supermihi/pytaglib/pull/123): upgrade to Taglib 2.0 + + Thanks to [Urs Fleisch](https://github.com/ufleisch) for help ## pytaglib 2.1.0 (2023-11-17) diff --git a/build_taglib.py b/build_taglib.py index 1436aba..6ab7f12 100644 --- a/build_taglib.py +++ b/build_taglib.py @@ -1,4 +1,5 @@ import hashlib +import os import platform import shutil import subprocess @@ -8,54 +9,88 @@ from argparse import ArgumentParser from pathlib import Path -is_x64 = sys.maxsize > 2**32 +is_x64 = sys.maxsize > 2 ** 32 arch = "x64" if is_x64 else "x32" system = platform.system() python_version = platform.python_version() here = Path(__file__).resolve().parent -default_taglib_path = here / "build" / "taglib" / f"{system}-{arch}-py{python_version}" -taglib_version = "1.13.1" +taglib_version = "2.0" taglib_release = f"https://github.com/taglib/taglib/archive/refs/tags/v{taglib_version}.tar.gz" -taglib_sha256sum = "c8da2b10f1bfec2cd7dbfcd33f4a2338db0765d851a50583d410bacf055cfd0b" +taglib_sha256sum = "e36ea877a6370810b97d84cf8f72b1e4ed205149ab3ac8232d44c850f38a2859" + +utfcpp_version = "4.0.5" +utfcpp_release = f"https://github.com/nemtrif/utfcpp/archive/refs/tags/v{utfcpp_version}.tar.gz" class Configuration: def __init__(self): - self.tl_install_dir = default_taglib_path self.build_path = here / "build" + self.tl_install_dir = self.build_path / "taglib" / f"{system}-{arch}-py{python_version}" self.clean = False @property def tl_download_dest(self): return self.build_path / f"taglib-{taglib_version}.tar.gz" + @property + def utfcpp_download_dest(self): + return self.build_path / f"utfcpp-{utfcpp_version}.tar.gz" + @property def tl_extract_dir(self): return self.build_path / f"taglib-{taglib_version}" + @property + def utfcpp_extract_dir(self): + return self.build_path / f"utfcpp-{utfcpp_version}" -def download(config: Configuration): - target = config.tl_download_dest + @property + def utfcpp_include_dir(self): + return self.utfcpp_extract_dir / "source" + + +def _download_file(url: str, target: Path, sha256sum: str = None): if target.exists(): print("skipping download, file exists") - else: - print(f"downloading taglib {taglib_version} ...") - response = urllib.request.urlopen(taglib_release) - data = response.read() - target.parent.mkdir(exist_ok=True, parents=True) - target.write_bytes(data) + return + print(f"downloading {url} ...") + response = urllib.request.urlopen(url) + data = response.read() + target.parent.mkdir(exist_ok=True, parents=True) + target.write_bytes(data) + if sha256sum is None: + return the_hash = hashlib.sha256(target.read_bytes()).hexdigest() - assert the_hash == taglib_sha256sum + if the_hash != taglib_sha256sum: + error = f'checksum of downloaded file ({the_hash}) does not match expected hash ({taglib_sha256sum})' + raise RuntimeError(error) + + +def download(config: Configuration): + _download_file(taglib_release, config.tl_download_dest, taglib_sha256sum) + _download_file(utfcpp_release, config.utfcpp_download_dest) + + +def _extract_tar(archive: Path, target: Path): + if target.exists(): + print(f"extracted directory {target} found; skipping tar") + return + print(f"extracting {archive} ...") + tar = tarfile.open(archive) + tar.extractall(target.parent) def extract(config: Configuration): - if config.tl_extract_dir.exists(): - print("extracted taglib found. Skipping tar") - else: - print("extracting tarball") - tar = tarfile.open(config.tl_download_dest) - tar.extractall(config.tl_extract_dir.parent) + _extract_tar(config.tl_download_dest, config.tl_extract_dir) + _extract_tar(config.utfcpp_download_dest, config.utfcpp_extract_dir) + + +def copy_utfcpp(config: Configuration): + target = config.tl_extract_dir / "3rdparty" / "utfcpp" + if target.exists(): + shutil.rmtree(target) + shutil.copytree(config.utfcpp_extract_dir, target) def cmake_clean(config: Configuration): @@ -85,6 +120,7 @@ def cmake_config(config: Configuration): elif system == "Linux": args.append("-DCMAKE_POSITION_INDEPENDENT_CODE=ON") args.append(f"-DCMAKE_INSTALL_PREFIX={config.tl_install_dir}") + args.append(f"-DCMAKE_CXX_FLAGS=-I{config.tl_extract_dir / '3rdparty' / 'utfcpp' / 'source'}") args.append(".") config.tl_install_dir.mkdir(exist_ok=True, parents=True) call_cmake(config, *args) @@ -132,15 +168,16 @@ def run(): print(f"building taglib on {system}, arch {arch}, for python {python_version} ...") config = parse_args() tag_lib = ( - config.tl_install_dir - / "lib" - / ("tag.lib" if system == "Windows" else "libtag.a") + config.tl_install_dir + / "lib" + / ("tag.lib" if system == "Windows" else "libtag.a") ) if tag_lib.exists() and not config.clean: print("installed TagLib found, exiting") return download(config) extract(config) + copy_utfcpp(config) cmake_clean(config) cmake_config(config) cmake_build(config) diff --git a/setup.py b/setup.py index 776022c..bd6df2f 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,8 @@ def extension_kwargs(): str(taglib_install_dir / "lib"), str(taglib_install_dir / "lib64"), ], + extra_compile_args=["-std=c++17"], + extra_link_args=["-std=c++17"], ) diff --git a/src/ctypes.pxd b/src/ctypes.pxd index 3cb661a..8c04d6b 100644 --- a/src/ctypes.pxd +++ b/src/ctypes.pxd @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright 2011-2018 Michael Helmling, michaelhelmling@posteo.de +# Copyright 2011-2024 Michael Helmling, michaelhelmling@posteo.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -15,6 +15,10 @@ from cpython.mem cimport PyMem_Free from cpython.object cimport PyObject +cdef extern from "Python.h": + cdef wchar_t *PyUnicode_AsWideCharString(PyObject *path, Py_ssize_t *size) + + cdef extern from 'taglib/tstring.h' namespace 'TagLib::String': cdef extern enum Type: Latin1, UTF16, UTF16BE, UTF8, UTF16LE @@ -42,14 +46,19 @@ cdef extern from 'taglib/tpropertymap.h' namespace 'TagLib': StringList& unsupportedData() int size() - + cdef extern from 'taglib/audioproperties.h' namespace 'TagLib': cdef cppclass AudioProperties: - int length() + int lengthInMilliseconds() int bitrate() int sampleRate() int channels() +cdef extern from 'taglib/audioproperties.h' namespace 'TagLib::AudioProperties': + cdef enum ReadStyle: + Fast = 0 + Average = 1 + Accurate = 2 cdef extern from 'taglib/tfile.h' namespace 'TagLib': cdef cppclass File: @@ -62,22 +71,33 @@ cdef extern from 'taglib/tfile.h' namespace 'TagLib': void removeUnsupportedProperties(StringList&) -IF UNAME_SYSNAME == "Windows": - cdef extern from 'taglib/fileref.h' namespace 'TagLib::FileRef': - cdef File * create(const wchar_t *) except + - cdef extern from "Python.h": - cdef wchar_t *PyUnicode_AsWideCharString(PyObject *path, Py_ssize_t *size) - cdef inline File* create_wrapper(unicode path): +cdef extern from 'taglib/tiostream.h' namespace 'TagLib': + IF UNAME_SYSNAME != "Windows": + ctypedef char* FileName + ELSE: + cdef cppclass FileName: + FileName(const wchar_t*) + +cdef extern from 'taglib/fileref.h' namespace 'TagLib': + cdef cppclass FileRef: + FileRef(FileName, boolean, ReadStyle) except + + File* file() + + AudioProperties *audioProperties() + bint save() except + + PropertyMap properties() + PropertyMap setProperties(PropertyMap&) + void removeUnsupportedProperties(StringList&) + +cdef inline FileRef* create_wrapper(unicode path) except +: + IF UNAME_SYSNAME != "Windows": + return new FileRef(path.encode('utf-8'), True, ReadStyle.Average) + ELSE: cdef wchar_t *wchar_path = PyUnicode_AsWideCharString(path, NULL) - cdef File * file = create(wchar_path) + cdef FileRef *file_ref = new FileRef(FileName(wchar_path), True, ReadStyle.Average) PyMem_Free(wchar_path) - return file -ELSE: - cdef extern from 'taglib/fileref.h' namespace 'TagLib::FileRef': - cdef File* create(const char*) except + - cdef inline File* create_wrapper(unicode path): - return create(path.encode('utf-8')) + return file_ref cdef extern from 'taglib/taglib.h': int TAGLIB_MAJOR_VERSION - int TAGLIB_MINOR_VERSION \ No newline at end of file + int TAGLIB_MINOR_VERSION diff --git a/src/taglib.pyx b/src/taglib.pyx index bcc09b8..3bdf61a 100644 --- a/src/taglib.pyx +++ b/src/taglib.pyx @@ -43,12 +43,12 @@ cdef dict propertyMapToDict(ctypes.PropertyMap map): cdef class File: """Class representing an audio file with metadata ("tags"). - + To read tags from an audio file, create a *File* object, passing the file's path to the constructor (should be a unicode string): - + >>> f = taglib.File('/path/to/file.ogg') - + The tags are stored in the attribute *tags* as a *dict* mapping strings (tag names) to lists of strings (tag values). @@ -59,30 +59,30 @@ cdef class File: as strings (e.g. cover art, proprietary data written by some programs, ...), according identifiers will be placed into the *unsupported* attribute of the File object. Using the method *removeUnsupportedProperties*, some or all of those can be removed. - + Additionally, the readonly attributes *length*, *bitrate*, *sampleRate*, and *channels* are available with their obvious meanings. >>> print('File length: {}'.format(f.length)) - + Changes to the *tags* attribute are stored using the *save* method. >>> f.save() """ - cdef ctypes.File *cFile + cdef ctypes.FileRef *cFile cdef public dict tags cdef readonly object path cdef readonly list unsupported cdef readonly object save_on_exit def __cinit__(self, path, save_on_exit: bool = False): - if not isinstance(path, os.PathLike): - if not isinstance(path, unicode): - path = path.decode('utf8') + if not isinstance(path, Path): + if isinstance(path, bytes): + path = path.decode('utf-8') path = Path(path) self.path = path - self.cFile = ctypes.create_wrapper(str(self.path)) - if not self.cFile or not self.cFile.isValid(): + self.cFile = ctypes.create_wrapper(str(path)) + if self.cFile is NULL or self.cFile.file() is NULL or not self.cFile.file().isValid(): raise OSError(f'Could not read file {path}') def __init__(self, path, save_on_exit: bool = False): @@ -93,11 +93,11 @@ cdef class File: cdef void readProperties(self): """Convert the Taglib::PropertyMap of the wrapped Taglib::File object into a python dict. - + This method is not accessible from Python, and is called only once, immediately after object creation. """ - + cdef: ctypes.PropertyMap cTags = self.cFile.properties() ctypes.String cString @@ -109,7 +109,7 @@ cdef class File: def save(self): """Store the tags currently hold in the `tags` attribute into the file. - + If some tags cannot be stored because the underlying metadata format does not support them, the unsuccesful tags are returned as a "sub-dictionary" of `self.tags` which will be empty if everything is ok. @@ -143,7 +143,7 @@ cdef class File: if not success: raise OSError('Unable to save tags: Unknown OS error') return propertyMapToDict(cRemaining) - + def removeUnsupportedProperties(self, properties): """This is a direct binding for the corresponding TagLib method.""" if not self.cFile: @@ -173,32 +173,32 @@ cdef class File: property length: def __get__(self): self.check_closed() - return self.cFile.audioProperties().length() - + return self.cFile.audioProperties().lengthInMilliseconds() / 1_000 + property bitrate: def __get__(self): self.check_closed() return self.cFile.audioProperties().bitrate() - + property sampleRate: def __get__(self): self.check_closed() return self.cFile.audioProperties().sampleRate() - + property channels: def __get__(self): self.check_closed() return self.cFile.audioProperties().channels() - + property readOnly: def __get__(self): self.check_closed() - return self.cFile.readOnly() + return self.cFile.file().readOnly() cdef check_closed(self): if self.is_closed: raise ValueError('I/O operation on closed file.') - + def __enter__(self): return self @@ -219,4 +219,4 @@ def taglib_version() -> tuple[int, int]: circumstances (e.g. dynamic linking, or re-using the cythonized code after upgrading Taglib) the actually running Taglib version might be different. """ - return ctypes.TAGLIB_MAJOR_VERSION, ctypes.TAGLIB_MINOR_VERSION \ No newline at end of file + return ctypes.TAGLIB_MAJOR_VERSION, ctypes.TAGLIB_MINOR_VERSION