]> kaliko git repositories - mpd-sima.git/blob - sima/lib/meta.py
doc: Update docstrings
[mpd-sima.git] / sima / lib / meta.py
1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2013, 2014, 2015, 2021 kaliko <kaliko@azylum.org>
3 #
4 #  This file is part of sima
5 #
6 #  sima is free software: you can redistribute it and/or modify
7 #  it under the terms of the GNU General Public License as published by
8 #  the Free Software Foundation, either version 3 of the License, or
9 #  (at your option) any later version.
10 #
11 #  sima is distributed in the hope that it will be useful,
12 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
13 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 #  GNU General Public License for more details.
15 #
16 #  You should have received a copy of the GNU General Public License
17 #  along with sima.  If not, see <http://www.gnu.org/licenses/>.
18 #
19 #
20 """
21 Defines some object to handle audio file metadata
22 """
23
24
25 from collections.abc import Set
26 import logging
27 import re
28
29 UUID_RE = r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[89AB][a-f0-9]{3}-[a-f0-9]{12}$'
30 #: The Track Object is collapsing multiple tags into a single string using this
31 # separator. It is used then to split back the string to tags list.
32 SEPARATOR = chr(0x1F)  # ASCII Unit Separator
33
34 def is_uuid4(uuid):
35     """Controls MusicBrainz UUID4 format
36
37     :param str uuid: String representing the UUID
38     :returns: boolean
39     """
40     regexp = re.compile(UUID_RE, re.IGNORECASE)
41     if regexp.match(uuid):
42         return True
43     return False
44
45 class MetaException(Exception):
46     """Generic Meta Exception"""
47
48
49 def mbidfilter(func):
50     def wrapper(*args, **kwargs):
51         cls = args[0]
52         if not cls.use_mbid:
53             kwargs.pop('mbid', None)
54             kwargs.pop('musicbrainz_artistid', None)
55             kwargs.pop('musicbrainz_albumartistid', None)
56         func(*args, **kwargs)
57     return wrapper
58
59
60 def serialize(func):
61     def wrapper(*args, **kwargs):
62         ans = func(*args, **kwargs)
63         if isinstance(ans, set):
64             return {s.replace("'", r"\'") for s in ans}
65         return ans.replace("'", r"\'")
66     return wrapper
67
68
69 class Meta:
70     """
71     A generic Class to handle tracks metadata such as artist, album, albumartist
72     names and their associated MusicBrainz's ID.
73
74
75     Using generic kwargs in constructor for convenience but the actual signature is:
76
77     >>> Meta(name, mbid=None, **kwargs)
78
79     :param str name: set name attribute
80     :param str mbid: set MusicBrainz ID
81     """
82     use_mbid = True
83     """Class attribute to disable use of MusicBrainz IDs"""
84
85     def __init__(self, **kwargs):
86         """Meta(name=<str>[, mbid=UUID4])"""
87         self.__name = None  # TODO: should be immutable
88         self.__mbid = None
89         self.__aliases = set()
90         self.log = logging.getLogger(__name__)
91         if 'name' not in kwargs or not kwargs.get('name'):
92             raise MetaException('Need a "name" argument (str type)')
93         if not isinstance(kwargs.get('name'), str):
94             raise MetaException('"name" argument not a string')
95         else:
96             self.__name = kwargs.pop('name').split(SEPARATOR)[0]
97         if 'mbid' in kwargs and kwargs.get('mbid'):
98             mbid = kwargs.get('mbid').lower().split(SEPARATOR)[0]
99             if is_uuid4(mbid):
100                 self.__mbid = mbid
101             else:
102                 self.log.warning('Wrong mbid %s:%s', self.__name, mbid)
103             # mbid immutable as hash rests on
104         self.__dict__.update(**kwargs)
105
106     def __repr__(self):
107         fmt = '{0}(name={1.name!r}, mbid={1.mbid!r})'
108         return fmt.format(self.__class__.__name__, self)
109
110     def __str__(self):
111         return self.__name.__str__()
112
113     def __eq__(self, other):
114         """
115         Perform mbid equality test
116         """
117         #if hasattr(other, 'mbid'):  # better isinstance?
118         if isinstance(other, Meta) and self.mbid and other.mbid:
119             return self.mbid == other.mbid
120         if isinstance(other, Meta):
121             return bool(self.names & other.names)
122         if getattr(other, '__str__', None):
123             # is other.__str__() in self.__name or self.__aliases
124             return other.__str__() in self.names
125         return False
126
127     def __hash__(self):
128         if self.mbid:
129             return hash(self.mbid)
130         return hash(self.__name)
131
132     def add_alias(self, other):
133         """Add alternative name to `aliases` attibute.
134
135         `other` can be a :class:`sima.lib.meta.Meta` object in which case aliases are merged.
136
137         :param str other: Alias to add, could be any object with ``__str__`` method.
138         """
139         if getattr(other, '__str__', None):
140             if callable(other.__str__) and other.__str__() != self.name:
141                 self.__aliases |= {other.__str__()}
142         elif isinstance(other, Meta):
143             if other.name != self.name:
144                 self.__aliases |= other.__aliases
145         else:
146             raise MetaException('No __str__ method found in {!r}'.format(other))
147
148     @property
149     def name(self):
150         return self.__name
151
152     @property
153     @serialize
154     def name_sz(self):
155         return self.name
156
157     @property
158     def mbid(self):
159         return self.__mbid
160
161     @mbid.setter
162     def mbid(self, mbid):
163         if mbid and not is_uuid4(mbid):
164             self.log.warning('Wrong mbid %s:%s', self.__name, mbid)
165             return
166         self.__mbid = mbid
167
168     @property
169     def aliases(self):
170         return self.__aliases
171
172     @property
173     @serialize
174     def aliases_sz(self):
175         return self.aliases
176
177     @property
178     def names(self):
179         """aliases + name"""
180         return self.__aliases | {self.__name,}
181
182     @property
183     @serialize
184     def names_sz(self):
185         return self.names
186
187
188 class Album(Meta):
189     """Album object"""
190
191     @mbidfilter
192     def __init__(self, name=None, mbid=None, **kwargs):
193         if kwargs.get('musicbrainz_albumid', False):
194             mbid = kwargs.get('musicbrainz_albumid')
195         super().__init__(name=name, mbid=mbid, **kwargs)
196
197     @property
198     def album(self):
199         return self.name
200
201
202 class Artist(Meta):
203     """Artist object deriving from :class:`Meta`.
204
205     :param str name: Artist name
206     :param str mbid: Musicbrainz artist ID
207     :param str artist: Overrides "name" argument
208     :param str albumartist: use "name" if not set
209     :param str musicbrainz_artistid: Overrides "mbid" argument
210
211     :Example:
212
213     >>> trk = {'artist':'Art Name',
214     >>>        'albumartist': 'Alb Art Name',           # optional
215     >>>        'musicbrainz_artistid': '<UUID4>',       # optional
216     >>>       }
217     >>> artobj0 = Artist(**trk)
218     >>> artobj1 = Artist(name='Tool')
219     """
220
221     @mbidfilter
222     def __init__(self, name=None, mbid=None, **kwargs):
223         if kwargs.get('artist', False):
224             name = kwargs.get('artist')
225         if kwargs.get('musicbrainz_artistid', False):
226             mbid = kwargs.get('musicbrainz_artistid')
227         if name and not kwargs.get('albumartist', False):
228             kwargs['albumartist'] = name.split(SEPARATOR)[0]
229         super().__init__(name=name, mbid=mbid,
230                          albumartist=kwargs.get('albumartist'))
231
232
233 class MetaContainer(Set):
234
235     def __init__(self, iterable):
236         self.elements = lst = []
237         for value in iterable:
238             if value not in lst:
239                 lst.append(value)
240             else:
241                 for inlst in lst:
242                     if value == inlst:
243                         inlst.add_alias(value)
244
245     def __iter__(self):
246         return iter(self.elements)
247
248     def __contains__(self, value):
249         return value in self.elements
250
251     def __len__(self):
252         return len(self.elements)
253
254     def __repr__(self):
255         return repr(self.elements)
256
257 # VIM MODLINE
258 # vim: ai ts=4 sw=4 sts=4 expandtab