Source code for xl.metadata._base

# Copyright (C) 2008-2010 Adam Olsen
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
#
# The developers of the Exaile media player hereby grant permission
# for non-GPL compatible GStreamer and Exaile plugins to be used and
# distributed together with GStreamer and Exaile. This permission is
# above and beyond the permissions granted by the GPL license by which
# Exaile is covered. If you modify this code, you may extend this
# exception to your version of the code, but you are not obligated to
# do so. If you do not wish to do so, delete this exception statement
# from your version.

from collections import namedtuple
import copy
import threading
from typing import Any, ClassVar, Mapping, Sequence

import logging

logger = logging.getLogger(__name__)

from xl import version, settings
import mutagen

version.register('Mutagen', mutagen.version_string)


INFO_TAGS = ['__bitrate', '__length']

_compute_lock = threading.Lock()

# Generic description of cover images
# - type is a number corresponding to the cover type of ID3 APIC tags,
#   desc is a string describing the image, mime is a type,
#   data is the img data
# -> if type is None, then the type is not changeable
CoverImage = namedtuple('CoverImage', 'type desc mime data')


[docs]class NotWritable(Exception): pass
[docs]class NotReadable(Exception): pass
[docs]class BaseFormat: """ Base class for handling loading of metadata from files. subclasses using mutagen should set MutagenType and overload the _get_tag, _set_tag, and _del_tag methods as needed. subclasses not using mutagen should leave MutagenType as None """ MutagenType = None # This should contain ALL keys supported by this filetype, unless 'others' # is set to True. If others is True, then anything not in tag_mapping will # be written using the Exaile tag name # -> dict k: exaile tag name, v: native tag name tag_mapping: ClassVar[Mapping[str, Any]] = {} others: ClassVar[bool] = True writable: ClassVar[bool] = False case_sensitive: ClassVar[bool] = True # Whether _compute_mappings has run _computed: ClassVar[bool] # Reverse of tag_mapping, populated in _compute_mappings _reverse_mapping: ClassVar[Mapping[Any, str]] @classmethod def _compute_mappings(cls): with _compute_lock: # this only needs to be run once per class if hasattr(cls, '_computed'): return if cls.case_sensitive: cls._reverse_mapping = {v: k for k, v in cls.tag_mapping.items()} else: cls._reverse_mapping = { v.lower(): k for k, v in cls.tag_mapping.items() } from .tags import disk_tags cls.ignore_tags = set(disk_tags) # This comes last to indicate success cls._computed = True def __init__(self, loc): """ Raises :class:`NotReadable` if the file cannot be opened for some reason. :param loc: absolute path to the file to read (note - this may change to accept gio uris in the future) """ self.loc = loc self.open = False self.mutagen = None try: self._computed except AttributeError: self.__class__._compute_mappings() self.load() def load(self): """ Loads the tags from the file. """ if self.MutagenType: try: self.mutagen = self.MutagenType(self.loc) except Exception: raise NotReadable def save(self): """ Saves any changes to the tags. """ if self.writable and self.mutagen: self.mutagen.save() def _del_tag(self, raw, tag): """ :param tag: The native tag name """ if tag in raw: del raw[tag] def _get_raw(self): if self.MutagenType or self.mutagen: return self.mutagen else: return {} def _get_tag(self, raw, tag): """ :param tag: The native tag name """ try: return raw[tag] except KeyError: return None def _set_tag(self, raw, tag, value): """ :param tag: The native tag name :param value: If None, delete the tag """ raw[tag] = value def get_keys_disk(self) -> Sequence[str]: """ Returns keys of all tags that can be read from disk """ return [self._reverse_mapping.get(k, k) for k in self._get_raw().keys()]
[docs] def read_all(self): """ Reads all non-blacklisted tags from the file. Blacklisted tags include lyrics, covers, and any field starting with __. If you need to read these, call read_tags directly. """ tags = INFO_TAGS[:] for t in self.get_keys_disk(): if t in self.ignore_tags: continue # __ is used to denote exaile's internal tags, so we skip # loading them to avoid conflicts. usually this shouldn't be # an issue. if self._remove_internal_tag(t): continue tags.append(t) alltags = self.read_tags(tags) return alltags
[docs] def read_tags(self, tags): """ get the values for the specified tags. returns a dict of the found values. if no value was found for a requested tag it will not exist in the returned dict. :param tags: a list of exaile tag names to read :returns: a dictionary of tag/value pairs. """ raw = self._get_raw() td = {} for tag in tags: t = None if tag in INFO_TAGS: try: t = self.get_info(tag) except KeyError: pass if t is None and tag in self.tag_mapping: try: t = self._get_tag(raw, self.tag_mapping[tag]) if isinstance(t, (str, bytes)): t = [t] elif isinstance(t, list): pass elif t is not None: t = [str(u) for u in t] except (KeyError, TypeError): logger.debug("Unexpected error reading `%s`", tag, exc_info=True) if t is None and self.others and tag not in self.tag_mapping: try: t = self._get_tag(raw, tag) if isinstance(t, (str, bytes)): t = [t] elif t is not None: t = [str(u) for u in t] except (KeyError, TypeError): logger.debug("Unexpected error reading `%s`", tag, exc_info=True) if t: td[tag] = t return td
[docs] def write_tags(self, tagdict): """ Write a set of tags to the file. Raises a NotWritable exception if the format does not support writing tags. When calling this function, we assume the following: * tagdict has all keys that you wish to write, keys are exaile tag names or custom tag names and values are the tags to write (lists of unicode strings) * if a value is None, then that tag will be deleted from the file * Will not modify/delete tags that are NOT in tagdict * Will not write tags that start with '__' :param tagdict: A dictionary of tag/value pairs to write. """ if not self.MutagenType or not self.writable: raise NotWritable else: tagdict = copy.deepcopy(tagdict) raw = self._get_raw() # Add tags if it doesn't have them. Mutagen will throw an exception # if the file already contains tags. if getattr(raw, 'tags', None) is None: try: raw.add_tags() except Exception: # XXX: Not sure needed since we're already checking for # existence of tags. pass # tags starting with __ are internal and should not be written # -> this covers INFO_TAGS, which also shouldn't be written for tag in list(tagdict.keys()): if self._remove_internal_tag(tag): del tagdict[tag] # Only modify the tags we were told to modify # -> if the value is None, delete the tag for tag, value in tagdict.items(): rtag = self.tag_mapping.get(tag) if rtag: if value is not None: self._set_tag(raw, rtag, value) else: self._del_tag(raw, rtag) elif self.others: if value is not None: self._set_tag(raw, tag, value) else: self._del_tag(raw, tag) self.save()
def get_info(self, info): # TODO: add sample rate? filesize? if info == "__length": return self.get_length() elif info == "__bitrate": return self.get_bitrate() else: raise KeyError def get_length(self): try: return self.mutagen.info.length except AttributeError: try: return self._get_raw()['__length'] except (KeyError, TypeError): return None def get_bitrate(self): try: return self.mutagen.info.bitrate except AttributeError: try: return self._get_raw()['__bitrate'] except (KeyError, TypeError): return None def _stars_to_rating(self, data: int) -> int: # exactly the same return int(data) def _rating_to_stars(self, data: int) -> int: # mapping according to kid3 # 0: 0 # 1: 1 - 29 # 2: 30 - 49 # 3: 50 - 69 # 4: 70 - 89 # 5: 90 - if data <= 0: # 0 rating = 0 elif data <= 29: # 1 rating = 20 elif data <= 49: # 2 rating = 40 elif data <= 69: # 3 rating = 60 elif data <= 89: # 4 rating = 80 else: # 5 rating = 100 return rating def _remove_internal_tag(self, tag): if tag.startswith("__") and ( tag != '__rating' or not settings.get_option( 'collection/write_rating_to_audio_file_metadata', False ) ): return True return False
class CaseInsensitiveBaseFormat(BaseFormat): case_sensitive = False def get_keys_disk(self): """ Returns keys of all tags that can be read from disk """ return [self._reverse_mapping.get(k.lower(), k) for k in self._get_raw().keys()] # vim: et sts=4 sw=4