Skip to content

Commit f0d38f9

Browse files
committed
Replace vendored musicbrainzngs with wnpmb package
Drop Python 3.10 support; minimum version is now Python 3.11 (required by wnpmb). **MusicBrainz client overhaul** Remove the vendored musicbrainzngs library (XML/aiohttp-based), the custom client.py (~420 lines), and parser.py (~340 lines). Replace with wnpmb — a JSON/httpx async MusicBrainz client — installed as a direct package dependency. helper.py rewritten to use wnpmb's API directly. **Release and recording selection improvements** wnpmb's select_best_release scores studio albums over singles/EPs, so album metadata is now more authoritative. find_recording() now receives a year hint (extracted via get_best_year(), which prefers originalyear > date > year) for better disambiguation of same-titled recordings across eras. artistwebsites moved out of the cached recording payload (config-dependent, must be computed fresh). **Discogs URL injection fix** Restore original gate logic: inject Discogs URL from MB artist relations when discogs/enabled OR musicbrainz/discogs is set. Migration had changed this to musicbrainz/discogs only, silently dropping URLs for users with Discogs enabled. **apicache event loop fix** asyncio.Lock is bound to the event loop at creation time. Replace the singleton lock with a property that recreates it when the running loop changes, fixing test isolation failures under per-function asyncio_mode. **tinytag bpm/key fixes** M4A stores bpm as a list where the first element may be a base64 float32 blob; scan the list for the first numeric string value. M4A initial_key (standard musical key) now takes priority over key (Serato internal code). Both fields unwrap list input correctly. **New utility** nowplaying/utils/metadata.py: get_best_year() centralises the originalyear/date/year precedence logic used across helper.py and processors.py. **Tests** Add unit tests for date normalisation, artist-in-title stripping, bpm multi-value decoding, and key field priority. Update integration tests to reflect improved release selection accuracy and correct multi-artist field handling.
1 parent 3fb9461 commit f0d38f9

27 files changed

Lines changed: 455 additions & 3781 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222

2323
* Developer Stuff
2424
* Dependency updates
25+
* **Python 3.10 is no longer supported**; minimum version is now Python 3.11
26+
* Replace vendored musicbrainzngs library with wnpmb package, removing ~3,500
27+
lines of XML-based MusicBrainz client code in favour of a JSON/httpx async
28+
client with improved release selection accuracy
2529

2630
## Version 5.1.0 - 2026-03-26
2731

builder.sh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ check_python_version() {
1818
pyver=$("${pybin}" --version 2>&1)
1919
pyver=${pyver#* }
2020
read -ra PY_VER <<< "${pyver}"
21-
if [[ ${PY_VER[0]} -ne 3 || ${PY_VER[1]} -lt 10 ]]; then
22-
echo "Python 3.10 or later is required (got ${pyver})."
21+
if [[ ${PY_VER[0]} -ne 3 || ${PY_VER[1]} -lt 11 ]]; then
22+
echo "Python 3.11 or later is required (got ${pyver})."
2323
exit 1
2424
fi
2525
if [[ ${PY_VER[1]} -ge 14 ]]; then
26-
echo "Python 3.14+ is not yet supported (got ${pyver}). Use Python 3.10–3.13."
26+
echo "Python 3.14+ is not yet supported (got ${pyver}). Use Python 3.11–3.13."
2727
exit 1
2828
fi
2929
}
@@ -41,7 +41,7 @@ if [[ "${SYSTEM}" == "dev" ]]; then
4141
esac
4242
PYTHONBIN="${PYTHONBIN:-$(command -v "${PYTHON}")}"
4343
if [[ -z "${PYTHONBIN}" ]]; then
44-
echo "Error: '${PYTHON}' not found on PATH. Please install Python 3.10–3.13."
44+
echo "Error: '${PYTHON}' not found on PATH. Please install Python 3.11–3.13."
4545
exit 1
4646
fi
4747
check_python_version "${PYTHONBIN}"

nowplaying/apicache.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ class APIResponseCache:
5959
"CREATE INDEX IF NOT EXISTS idx_last_accessed ON api_responses(last_accessed);",
6060
]
6161

62+
@property
63+
def _lock(self) -> asyncio.Lock:
64+
"""Return an asyncio.Lock valid for the current event loop.
65+
66+
A new lock is created whenever the running loop changes — this keeps
67+
tests that use per-function event loops from inheriting a stale lock
68+
created by an earlier test's loop.
69+
"""
70+
try:
71+
current_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
72+
except RuntimeError:
73+
current_loop = None
74+
if self.__lock is None or self.__lock_loop is not current_loop:
75+
self.__lock_loop = current_loop
76+
self.__lock = asyncio.Lock()
77+
return self.__lock
78+
6279
def __init__(self, cache_dir: pathlib.Path | None = None):
6380
"""Initialize the API cache.
6481
@@ -74,7 +91,8 @@ def __init__(self, cache_dir: pathlib.Path | None = None):
7491

7592
self.cache_dir.mkdir(parents=True, exist_ok=True)
7693
self.db_file = self.cache_dir / "api_responses.db"
77-
self._lock = asyncio.Lock()
94+
self.__lock: asyncio.Lock | None = None
95+
self.__lock_loop: asyncio.AbstractEventLoop | None = None
7896

7997
# Initialize database if needed
8098
self._init_task = asyncio.create_task(self._initialize_db())

nowplaying/bootstrap.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
def verify_python_version():
1515
"""make sure the correct version of python is being used"""
1616

17-
if sys.version_info[0] < 3 or (sys.version_info[0] == 3 and sys.version_info[1] < 10):
17+
if sys.version_info[0] < 3 or (sys.version_info[0] == 3 and sys.version_info[1] < 11):
1818
msgbox = QErrorMessage()
19-
msgbox.showMessage("Python Version must be 3.10 or higher. Exiting.")
19+
msgbox.showMessage("Python Version must be 3.11 or higher. Exiting.")
2020
msgbox.show()
2121
msgbox.exec()
2222
return False
@@ -94,4 +94,7 @@ def setuplogging(
9494
level=logging.DEBUG,
9595
)
9696
logging.captureWarnings(True)
97+
# httpx emits very noisy DEBUG-level connection tracing
98+
logging.getLogger("httpx").setLevel(logging.WARNING)
99+
logging.getLogger("httpcore").setLevel(logging.WARNING)
97100
return logpath

nowplaying/metadata/processors.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,26 @@ async def getmoremetadata( # pylint: disable=too-many-branches
111111
self.metadata["artist"] = parts[0].strip()
112112
self.metadata["title"] = parts[1].strip()
113113

114-
if (
115-
" - " in self.metadata.get("title", "")
116-
and self.metadata.get("artist")
117-
and self.metadata["artist"] in self.metadata.get("title", "")
118-
):
119-
logging.debug("Removing extra artist - from title")
120-
parts = self.metadata["title"].split(" - ", 1)
121-
self.metadata["title"] = parts[1].strip()
114+
if " - " in self.metadata.get("title", "") and self.metadata.get("artist"):
115+
artist = self.metadata["artist"]
116+
title = self.metadata["title"]
117+
if artist in title or artist.translate(nowplaying.utils.CUSTOM_TRANSLATE) in title:
118+
logging.debug("Removing extra artist - from title")
119+
parts = title.split(" - ", 1)
120+
prefix = parts[0].strip()
121+
self.metadata["title"] = parts[1].strip()
122+
# If the prefix has a feat. component, append it to the current
123+
# artist so MB search can match all credited artists.
124+
# Preserve the existing (possibly non-ASCII) artist name so that
125+
# wnpmb's arid-based Pass 0 still fires for non-Latin scripts.
126+
for sep in [" feat.", " ft.", " featuring", " with "]:
127+
idx = prefix.lower().find(sep)
128+
if idx != -1:
129+
feat_part = prefix[idx:]
130+
new_artist = artist + feat_part
131+
logging.debug("Appending feat. to artist: %s", new_artist)
132+
self.metadata["artist"] = new_artist
133+
break
122134

123135
await self._process_plugins(skipplugins)
124136

@@ -154,6 +166,13 @@ def _fix_dates(self) -> None:
154166
if "date" in self.metadata and (not self.metadata["date"] or self.metadata["date"] == "0"):
155167
del self.metadata["date"]
156168

169+
if date := self.metadata.get("date"):
170+
date = str(date)
171+
if len(date) == 8 and date.isdigit():
172+
self.metadata["date"] = f"{date[:4]}-{date[4:6]}-{date[6:]}"
173+
elif len(date) == 6 and date.isdigit():
174+
self.metadata["date"] = f"{date[:4]}-{date[4:]}"
175+
157176
def _fix_duration(self) -> None:
158177
if not self.metadata or not self.metadata.get("duration"):
159178
return

nowplaying/metadata/tinytag_runner.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,38 @@ def _detect_video_content(file_path: pathlib.Path) -> bool: # pylint: disable=t
246246
)
247247
return False # Still default to audio, but log as error for investigation
248248

249+
@staticmethod
250+
def _decode_bpm(bpm_value) -> str | None:
251+
"""Extract a valid numeric BPM from a field value.
252+
253+
M4A files can have multiple 'bpm' atoms: a raw float32 binary blob
254+
followed by the human-readable string (e.g. ['AAD8Qg==', '126']).
255+
Return the first value that parses as a finite float.
256+
"""
257+
if not bpm_value:
258+
return None
259+
values = bpm_value if isinstance(bpm_value, list) else [bpm_value]
260+
for v in values:
261+
try:
262+
f = float(str(v))
263+
if f > 0:
264+
return str(v)
265+
except (ValueError, TypeError):
266+
pass
267+
return None
268+
249269
@staticmethod
250270
def _decode_musical_key(key_value) -> str | None:
251271
"""Decode musical key field, handling JSON structures from MixedInKey."""
252272
if not key_value:
253273
return None
254274

275+
# M4A custom atoms surface as a list; take the first element.
276+
if isinstance(key_value, list):
277+
key_value = key_value[0] if key_value else None
278+
if not key_value:
279+
return None
280+
255281
key_str = str(key_value).strip()
256282

257283
# Check if it looks like base64 encoded JSON
@@ -277,6 +303,7 @@ def _process_extra(self, extra: dict[str, object]) -> None:
277303
"acoustid id": "acoustidid",
278304
"bpm": "bpm",
279305
"isrc": "isrc",
306+
"initial_key": "key",
280307
"key": "key",
281308
"composer": "composer",
282309
"lyricist": "lyricist",
@@ -300,9 +327,12 @@ def _process_extra(self, extra: dict[str, object]) -> None:
300327

301328
if key in list_fields:
302329
self._process_list_field(extra[key], newkey)
303-
elif key == "key":
330+
elif key in ("key", "initial_key"):
304331
# Special handling for musical key field
305332
self.metadata[newkey] = self._decode_musical_key(extra[key])
333+
elif key == "bpm":
334+
# M4A can store bpm as multiple atoms; pick the numeric one
335+
self.metadata[newkey] = self._decode_bpm(extra[key])
306336
else:
307337
self._process_single_field(extra[key], newkey)
308338

0 commit comments

Comments
 (0)