Skip to content

Commit

Permalink
Merge pull request #123 from supermihi/taglib-2
Browse files Browse the repository at this point in the history
Upgrade to Taglib 2
  • Loading branch information
supermihi authored Mar 16, 2024
2 parents c4f9d2f + 8dc4b79 commit 2eb4650
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 63 deletions.
1 change: 1 addition & 0 deletions .github/workflows/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
83 changes: 60 additions & 23 deletions build_taglib.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import hashlib
import os
import platform
import shutil
import subprocess
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)


Expand Down
54 changes: 37 additions & 17 deletions src/ctypes.pxd
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2011-2018 Michael Helmling, [email protected]
# Copyright 2011-2024 Michael Helmling, [email protected]
#
# 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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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(<PyObject*>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
int TAGLIB_MINOR_VERSION
46 changes: 23 additions & 23 deletions src/taglib.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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
return ctypes.TAGLIB_MAJOR_VERSION, ctypes.TAGLIB_MINOR_VERSION

0 comments on commit 2eb4650

Please sign in to comment.