]> kaliko git repositories - mpd-sima.git/blob - sima/lib/meta.py
More robust MPD client (start addressing #53)
[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"\'").replace('"', r'\"') for s in ans}
65         return ans.replace("'", r"\'").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 isinstance(other, Meta):
140             self.__aliases |= other.__aliases
141             self.__aliases -= {self.name}
142         if getattr(other, '__str__', None):
143             if callable(other.__str__) and other.__str__() != self.name:
144                 self.__aliases |= {other.__str__()}
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     @property
162     def aliases(self):
163         return self.__aliases
164
165     @property
166     @serialize
167     def aliases_sz(self):
168         return self.aliases
169
170     @property
171     def names(self):
172         """aliases + name"""
173         return self.__aliases | {self.__name,}
174
175     @property
176     @serialize
177     def names_sz(self):
178         return self.names
179
180
181 class Album(Meta):
182     """Album object"""
183
184     @mbidfilter
185     def __init__(self, name=None, mbid=None, **kwargs):
186         if kwargs.get('musicbrainz_albumid', False):
187             mbid = kwargs.get('musicbrainz_albumid')
188         super().__init__(name=name, mbid=mbid, **kwargs)
189
190     @property
191     def album(self):
192         return self.name
193
194
195 class Artist(Meta):
196     """Artist object deriving from :class:`Meta`.
197
198     :param str name: Artist name
199     :param str mbid: Musicbrainz artist ID
200     :param str artist: Overrides "name" argument
201     :param str albumartist: use "name" if not set
202     :param str musicbrainz_artistid: Overrides "mbid" argument
203
204     :Example:
205
206     >>> trk = {'artist':'Art Name',
207     >>>        'albumartist': 'Alb Art Name',           # optional
208     >>>        'musicbrainz_artistid': '<UUID4>',       # optional
209     >>>       }
210     >>> artobj0 = Artist(**trk)
211     >>> artobj1 = Artist(name='Tool')
212     """
213
214     @mbidfilter
215     def __init__(self, name=None, mbid=None, **kwargs):
216         if kwargs.get('artist', False):
217             name = kwargs.get('artist')
218         if kwargs.get('musicbrainz_artistid', False):
219             mbid = kwargs.get('musicbrainz_artistid')
220         if name and not kwargs.get('albumartist', False):
221             kwargs['albumartist'] = name.split(SEPARATOR)[0]
222         super().__init__(name=name, mbid=mbid,
223                          albumartist=kwargs.get('albumartist'))
224
225
226 class MetaContainer(Set):
227
228     def __init__(self, iterable):
229         self.elements = lst = []
230         for value in iterable:
231             if value not in lst:
232                 lst.append(value)
233             else:
234                 for inlst in lst:
235                     if value == inlst:
236                         inlst.add_alias(value)
237
238     def __iter__(self):
239         return iter(self.elements)
240
241     def __contains__(self, value):
242         return value in self.elements
243
244     def __len__(self):
245         return len(self.elements)
246
247     def __repr__(self):
248         return repr(self.elements)
249
250 # VIM MODLINE
251 # vim: ai ts=4 sw=4 sts=4 expandtab