Skip to content

Commit fcb526e

Browse files
committed
Add mbpseudo plugin
1 parent 921fa66 commit fcb526e

File tree

1 file changed

+274
-0
lines changed

1 file changed

+274
-0
lines changed

beetsplug/mbpseudo.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# This file is part of beets.
2+
# Copyright 2025, Alexis Sarda-Espinosa.
3+
#
4+
# Permission is hereby granted, free of charge, to any person obtaining
5+
# a copy of this software and associated documentation files (the
6+
# "Software"), to deal in the Software without restriction, including
7+
# without limitation the rights to use, copy, modify, merge, publish,
8+
# distribute, sublicense, and/or sell copies of the Software, and to
9+
# permit persons to whom the Software is furnished to do so, subject to
10+
# the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be
13+
# included in all copies or substantial portions of the Software.
14+
15+
"""Adds pseudo-releases from MusicBrainz as candidates during import."""
16+
17+
from typing import Iterable, Sequence
18+
19+
from typing_extensions import override
20+
21+
import beetsplug.musicbrainz as mbplugin # avoid implicit loading of main plugin
22+
from beets.autotag import AlbumInfo, Distance
23+
from beets.autotag.distance import distance
24+
from beets.autotag.hooks import V, TrackInfo
25+
from beets.autotag.match import assign_items
26+
from beets.library import Item
27+
from beets.metadata_plugins import MetadataSourcePlugin
28+
from beets.plugins import find_plugins
29+
from beetsplug._typing import JSONDict
30+
31+
32+
class MusicBrainzPseudoReleasePlugin(MetadataSourcePlugin):
33+
def __init__(self, *args, **kwargs) -> None:
34+
super().__init__(*args, **kwargs)
35+
self.config.add({"scripts": []})
36+
self._scripts = self.config["scripts"].as_str_seq()
37+
self._mb = mbplugin.MusicBrainzPlugin()
38+
self._pseudo_release_ids: dict[str, list[str]] = {}
39+
self._intercepted_candidates: dict[str, AlbumInfo] = {}
40+
41+
self.register_listener("mb_album_extract", self._intercept_mb_releases)
42+
self.register_listener(
43+
"albuminfo_received", self._intercept_mb_candidates
44+
)
45+
46+
self._log.debug("Desired scripts: {0}", self._scripts)
47+
48+
def _intercept_mb_releases(self, data: JSONDict):
49+
album_id = data["id"] if ("id" in data) else None
50+
if (
51+
not isinstance(album_id, str)
52+
or album_id in self._pseudo_release_ids
53+
):
54+
return None
55+
56+
pseudo_release_ids = (
57+
self._wanted_pseudo_release_id(rel)
58+
for rel in data.get("release-relation-list", [])
59+
)
60+
pseudo_release_ids = [
61+
rel for rel in pseudo_release_ids if rel is not None
62+
]
63+
64+
if len(pseudo_release_ids) > 0:
65+
self._log.debug("Intercepted release with album id {0}", album_id)
66+
self._pseudo_release_ids[album_id] = pseudo_release_ids
67+
68+
return None
69+
70+
def _wanted_pseudo_release_id(
71+
self,
72+
relation: JSONDict,
73+
) -> str | None:
74+
if (
75+
len(self._scripts) == 0
76+
or relation.get("type", "") != "transl-tracklisting"
77+
or relation.get("direction", "") != "forward"
78+
or "release" not in relation
79+
):
80+
return None
81+
82+
release = relation["release"]
83+
script = release.get("text-representation", {}).get(
84+
"script", self._scripts[0]
85+
)
86+
87+
if "id" in release and script in self._scripts:
88+
return release["id"]
89+
else:
90+
return None
91+
92+
def _intercept_mb_candidates(self, info: AlbumInfo):
93+
if (
94+
not isinstance(info, PseudoAlbumInfo)
95+
and info.album_id in self._pseudo_release_ids
96+
and info.album_id not in self._intercepted_candidates
97+
):
98+
self._log.debug(
99+
"Intercepted candidate with album id {0.album_id}", info
100+
)
101+
self._intercepted_candidates[info.album_id] = info.copy()
102+
103+
def candidates(
104+
self,
105+
items: Sequence[Item],
106+
artist: str,
107+
album: str,
108+
va_likely: bool,
109+
) -> Iterable[AlbumInfo]:
110+
if len(self._scripts) == 0:
111+
return []
112+
113+
try:
114+
item_paths = {item.path for item in items}
115+
official_release_id = next(
116+
key
117+
for key, info in self._intercepted_candidates.items()
118+
if "mapping" in info
119+
and all(
120+
mapping_key.path in item_paths
121+
for mapping_key in info.mapping.keys()
122+
)
123+
)
124+
pseudo_release_ids = self._pseudo_release_ids[official_release_id]
125+
self._log.debug(
126+
"Processing pseudo-releases for {0}: {1}",
127+
official_release_id,
128+
pseudo_release_ids,
129+
)
130+
except StopIteration:
131+
official_release_id = None
132+
pseudo_release_ids = []
133+
134+
if official_release_id is not None:
135+
pseudo_releases: list[AlbumInfo] = []
136+
for pri in pseudo_release_ids:
137+
if match := self._mb.album_for_id(pri):
138+
pseudo_album_info = PseudoAlbumInfo(
139+
pseudo_release=match,
140+
official_release=self._intercepted_candidates[
141+
official_release_id
142+
],
143+
data_source=self.data_source,
144+
)
145+
self._log.debug(
146+
"Using {0} release for distance calculations for album {1}",
147+
pseudo_album_info.determine_best_ref(items),
148+
pri,
149+
)
150+
pseudo_releases.append(pseudo_album_info)
151+
152+
del self._pseudo_release_ids[official_release_id]
153+
del self._intercepted_candidates[official_release_id]
154+
return pseudo_releases
155+
156+
if any(
157+
isinstance(plugin, mbplugin.MusicBrainzPlugin)
158+
for plugin in find_plugins()
159+
):
160+
self._log.debug("No releases found by main MusicBrainz plugin")
161+
return []
162+
163+
# musicbrainz plugin isn't enabled
164+
self._log.debug("Searching for official releases")
165+
official_candidates = list(
166+
self._mb.candidates(items, artist, album, va_likely)
167+
)
168+
169+
recursion = False
170+
for official_candidate in official_candidates:
171+
if official_candidate.album_id in self._pseudo_release_ids:
172+
self._intercept_mb_candidates(official_candidate)
173+
if official_candidate.album_id in self._intercepted_candidates:
174+
intercepted = self._intercepted_candidates[
175+
official_candidate.album_id
176+
]
177+
intercepted.mapping, _, _ = assign_items(
178+
items, intercepted.tracks
179+
)
180+
recursion = True
181+
182+
# TODO include official candidates?
183+
if recursion:
184+
return self.candidates(items, artist, album, va_likely)
185+
else:
186+
return []
187+
188+
@override
189+
def album_distance(
190+
self,
191+
items: Sequence[Item],
192+
album_info: AlbumInfo,
193+
mapping: dict[Item, TrackInfo],
194+
) -> Distance:
195+
if isinstance(album_info, PseudoAlbumInfo):
196+
if not isinstance(mapping, ImmutableMapping):
197+
self._log.debug(
198+
"Switching {0.album_id} to pseudo-release source for final proposal",
199+
album_info,
200+
)
201+
album_info.use_pseudo_as_ref()
202+
new_mappings, _, _ = assign_items(items, album_info.tracks)
203+
mapping.update(new_mappings)
204+
205+
elif album_info.album_id in self._intercepted_candidates:
206+
self._log.debug("Storing mapping for {0.album_id}", album_info)
207+
self._intercepted_candidates[album_info.album_id].mapping = mapping
208+
209+
return super().album_distance(items, album_info, mapping)
210+
211+
def album_for_id(self, album_id: str) -> AlbumInfo | None:
212+
pass
213+
214+
def track_for_id(self, track_id: str) -> TrackInfo | None:
215+
pass
216+
217+
def item_candidates(
218+
self,
219+
item: Item,
220+
artist: str,
221+
title: str,
222+
) -> Iterable[TrackInfo]:
223+
return []
224+
225+
226+
class PseudoAlbumInfo(AlbumInfo):
227+
def __init__(
228+
self,
229+
pseudo_release: AlbumInfo,
230+
official_release: AlbumInfo,
231+
**kwargs,
232+
):
233+
super().__init__(pseudo_release.tracks, **kwargs)
234+
self.__dict__["_pseudo_source"] = True
235+
self.__dict__["_official_release"] = official_release
236+
for k, v in pseudo_release.items():
237+
if k not in kwargs:
238+
self[k] = v
239+
240+
def determine_best_ref(self, items: Sequence[Item]) -> str:
241+
self.use_pseudo_as_ref()
242+
pseudo_dist = self._compute_distance(items)
243+
244+
self.use_official_as_ref()
245+
official_dist = self._compute_distance(items)
246+
247+
if official_dist < pseudo_dist:
248+
self.use_official_as_ref()
249+
return "official"
250+
else:
251+
self.use_pseudo_as_ref()
252+
return "pseudo"
253+
254+
def _compute_distance(self, items: Sequence[Item]) -> Distance:
255+
mapping, _, _ = assign_items(items, self.tracks)
256+
return distance(items, self, ImmutableMapping(mapping))
257+
258+
def use_pseudo_as_ref(self):
259+
self.__dict__["_pseudo_source"] = True
260+
261+
def use_official_as_ref(self):
262+
self.__dict__["_pseudo_source"] = False
263+
264+
def __getattr__(self, attr: str) -> V:
265+
# ensure we don't duplicate an official release's id by always returning pseudo's
266+
if self.__dict__["_pseudo_source"] or attr == "album_id":
267+
return super().__getattr__(attr)
268+
else:
269+
return self.__dict__["_official_release"].__getattr__(attr)
270+
271+
272+
class ImmutableMapping(dict[Item, TrackInfo]):
273+
def __init__(self, *args, **kwargs):
274+
super().__init__(*args, **kwargs)

0 commit comments

Comments
 (0)