Refactor picard.formats and picard.coverart.providers to avoid circular dependencies

This commit is contained in:
Philipp Wolfer
2020-05-12 17:04:02 +02:00
parent 8c219d16a5
commit d79d142a04
10 changed files with 254 additions and 190 deletions

View File

@@ -7,7 +7,7 @@
# Copyright (C) 2016 Ville Skyttä
# Copyright (C) 2016 Wieland Hoffmann
# Copyright (C) 2017 Sambhav Kothari
# Copyright (C) 2019 Philipp Wolfer
# Copyright (C) 2019-2020 Philipp Wolfer
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
@@ -28,7 +28,6 @@ from collections import (
defaultdict,
namedtuple,
)
import traceback
from picard import (
config,
@@ -39,51 +38,19 @@ from picard.coverart.providers.caa_release_group import (
CoverArtProviderCaaReleaseGroup,
)
from picard.coverart.providers.local import CoverArtProviderLocal
from picard.coverart.providers.provider import ( # noqa: F401 # pylint: disable=unused-import
CoverArtProvider,
ProviderOptions,
)
from picard.coverart.providers.whitelist import CoverArtProviderWhitelist
from picard.plugin import ExtensionPoint
from picard.ui.options import (
OptionsPage,
register_options_page,
)
from picard.ui.options import register_options_page
_cover_art_providers = ExtensionPoint(label='cover_art_providers')
class ProviderOptions(OptionsPage):
""" Template class for provider's options
It works like OptionsPage for the most (options, load, save)
It will append the provider's options page as a child of the main
cover art's options page.
The property _options_ui must be set to a valid Qt Ui class
containing the layout and widgets for defined provider's options.
A specific provider class (inhereting from CoverArtProvider) has
to set the subclassed ProviderOptions as OPTIONS property.
Options will be registered at the same time as the provider.
class MyProviderOptions(ProviderOptions):
_options_ui = Ui_MyProviderOptions
....
class MyProvider(CoverArtProvider):
OPTIONS = ProviderOptionsMyProvider
....
"""
PARENT = "cover"
def __init__(self, parent=None):
super().__init__(parent)
self.ui = self._options_ui()
self.ui.setupUi(self)
def register_cover_art_provider(provider):
_cover_art_providers.register(provider.__module__, provider)
if hasattr(provider, 'OPTIONS') and provider.OPTIONS:
@@ -123,79 +90,6 @@ def cover_art_providers():
yield ProviderTuple(name=p.name, title=p.title, enabled=order[p.name].enabled, cls=p)
class CoverArtProviderMetaClass(type):
"""Provide default properties name & title for CoverArtProvider
It is recommended to use those in place of NAME and TITLE that might not be defined
"""
@property
def name(cls):
return getattr(cls, 'NAME', cls.__name__)
@property
def title(cls):
return getattr(cls, 'TITLE', cls.name)
class CoverArtProvider(metaclass=CoverArtProviderMetaClass):
"""Subclasses of this class need to reimplement at least `queue_images()`.
`__init__()` does not have to do anything.
`queue_images()` will be called if `enabled()` returns `True`.
`queue_images()` must return `FINISHED` when it finished to queue
potential cover art downloads (using `queue_put(<CoverArtImage object>).
If `queue_images()` delegates the job of queuing downloads to another
method (asynchronous) it should return `WAIT` and the other method has to
explicitly call `next_in_queue()`.
If `FINISHED` is returned, `next_in_queue()` will be automatically called
by CoverArt object.
"""
# default state, internal use
_STARTED = 0
# returned by queue_images():
# next_in_queue() will be automatically called
FINISHED = 1
# returned by queue_images():
# next_in_queue() has to be called explicitly by provider
WAIT = 2
def __init__(self, coverart):
self.coverart = coverart
self.release = coverart.release
self.metadata = coverart.metadata
self.album = coverart.album
def enabled(self):
return not self.coverart.front_image_found
def queue_images(self):
# this method has to return CoverArtProvider.FINISHED or
# CoverArtProvider.WAIT
raise NotImplementedError
def error(self, msg):
self.coverart.album.error_append(msg)
def queue_put(self, what):
self.coverart.queue_put(what)
def next_in_queue(self):
# must be called by provider if queue_images() returns WAIT
self.coverart.next_in_queue()
def match_url_relations(self, relation_types, func):
"""Execute `func` for each relation url matching type in
`relation_types`
"""
try:
if 'relations' in self.release:
for relation in self.release['relations']:
if relation['target-type'] == 'url':
if relation['type'] in relation_types:
func(relation['url']['resource'])
except AttributeError:
self.error(traceback.format_exc())
__providers = [
CoverArtProviderLocal,
CoverArtProviderCaa,

View File

@@ -57,7 +57,7 @@ from picard.coverart.image import (
CaaCoverArtImage,
CaaThumbnailCoverArtImage,
)
from picard.coverart.providers import (
from picard.coverart.providers.provider import (
CoverArtProvider,
ProviderOptions,
)

View File

@@ -27,7 +27,7 @@ import re
from picard import config
from picard.coverart.image import LocalFileCoverArtImage
from picard.coverart.providers import (
from picard.coverart.providers.provider import (
CoverArtProvider,
ProviderOptions,
)

View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2014-2015, 2018-2019 Laurent Monin
# Copyright (C) 2015 Rahul Raturi
# Copyright (C) 2016 Ville Skyttä
# Copyright (C) 2016 Wieland Hoffmann
# Copyright (C) 2017 Sambhav Kothari
# Copyright (C) 2019-2020 Philipp Wolfer
#
# 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
# of the License, 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.
import traceback
from picard.ui.options import OptionsPage
class ProviderOptions(OptionsPage):
""" Template class for provider's options
It works like OptionsPage for the most (options, load, save)
It will append the provider's options page as a child of the main
cover art's options page.
The property _options_ui must be set to a valid Qt Ui class
containing the layout and widgets for defined provider's options.
A specific provider class (inhereting from CoverArtProvider) has
to set the subclassed ProviderOptions as OPTIONS property.
Options will be registered at the same time as the provider.
class MyProviderOptions(ProviderOptions):
_options_ui = Ui_MyProviderOptions
....
class MyProvider(CoverArtProvider):
OPTIONS = ProviderOptionsMyProvider
....
"""
PARENT = "cover"
def __init__(self, parent=None):
super().__init__(parent)
self.ui = self._options_ui()
self.ui.setupUi(self)
class CoverArtProviderMetaClass(type):
"""Provide default properties name & title for CoverArtProvider
It is recommended to use those in place of NAME and TITLE that might not be defined
"""
@property
def name(cls):
return getattr(cls, 'NAME', cls.__name__)
@property
def title(cls):
return getattr(cls, 'TITLE', cls.name)
class CoverArtProvider(metaclass=CoverArtProviderMetaClass):
"""Subclasses of this class need to reimplement at least `queue_images()`.
`__init__()` does not have to do anything.
`queue_images()` will be called if `enabled()` returns `True`.
`queue_images()` must return `FINISHED` when it finished to queue
potential cover art downloads (using `queue_put(<CoverArtImage object>).
If `queue_images()` delegates the job of queuing downloads to another
method (asynchronous) it should return `WAIT` and the other method has to
explicitly call `next_in_queue()`.
If `FINISHED` is returned, `next_in_queue()` will be automatically called
by CoverArt object.
"""
# default state, internal use
_STARTED = 0
# returned by queue_images():
# next_in_queue() will be automatically called
FINISHED = 1
# returned by queue_images():
# next_in_queue() has to be called explicitly by provider
WAIT = 2
def __init__(self, coverart):
self.coverart = coverart
self.release = coverart.release
self.metadata = coverart.metadata
self.album = coverart.album
def enabled(self):
return not self.coverart.front_image_found
def queue_images(self):
# this method has to return CoverArtProvider.FINISHED or
# CoverArtProvider.WAIT
raise NotImplementedError
def error(self, msg):
self.coverart.album.error_append(msg)
def queue_put(self, what):
self.coverart.queue_put(what)
def next_in_queue(self):
# must be called by provider if queue_images() returns WAIT
self.coverart.next_in_queue()
def match_url_relations(self, relation_types, func):
"""Execute `func` for each relation url matching type in
`relation_types`
"""
try:
if 'relations' in self.release:
for relation in self.release['relations']:
if relation['target-type'] == 'url':
if relation['type'] in relation_types:
func(relation['url']['resource'])
except AttributeError:
self.error(traceback.format_exc())

View File

@@ -27,7 +27,7 @@
from picard import log
from picard.coverart.image import CoverArtImage
from picard.coverart.providers import CoverArtProvider
from picard.coverart.providers.provider import CoverArtProvider
class CoverArtProviderWhitelist(CoverArtProvider):

View File

@@ -172,7 +172,7 @@ class File(QtCore.QObject, Item):
# Try loading based on extension first
return self._load(filename)
except Exception:
from picard.formats import guess_format
from picard.formats.util import guess_format
# If it fails, force format guessing and try loading again
file_format = guess_format(filename)
if not file_format and type(file_format) == type(self):

View File

@@ -4,7 +4,7 @@
#
# Copyright (C) 2006-2008, 2012 Lukáš Lalinský
# Copyright (C) 2008 Will
# Copyright (C) 2010, 2014, 2018-2019 Philipp Wolfer
# Copyright (C) 2010, 2014, 2018-2020 Philipp Wolfer
# Copyright (C) 2013 Michael Wiencek
# Copyright (C) 2013, 2017-2019 Laurent Monin
# Copyright (C) 2016-2018 Sambhav Kothari
@@ -27,7 +27,6 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
from picard import log
from picard.formats.ac3 import AC3File
from picard.formats.apev2 import (
AACFile,
@@ -46,6 +45,14 @@ from picard.formats.id3 import (
)
from picard.formats.midi import MIDIFile
from picard.formats.mp4 import MP4File
from picard.formats.util import ( # noqa: F401 # pylint: disable=unused-import
ext_to_format,
guess_format,
open_,
register_format,
supported_extensions,
supported_formats,
)
from picard.formats.vorbis import (
FLACFile,
OggAudioFile,
@@ -57,76 +64,6 @@ from picard.formats.vorbis import (
OggVorbisFile,
)
from picard.formats.wav import WAVFile
from picard.plugin import ExtensionPoint
_formats = ExtensionPoint(label='formats')
_extensions = {}
def register_format(file_format):
_formats.register(file_format.__module__, file_format)
for ext in file_format.EXTENSIONS:
_extensions[ext[1:]] = file_format
def supported_formats():
"""Returns list of supported formats."""
return [(file_format.EXTENSIONS, file_format.NAME) for file_format in _formats]
def supported_extensions():
"""Returns list of supported extensions."""
return [ext for exts, name in supported_formats() for ext in exts]
def ext_to_format(ext):
return _extensions.get(ext, None)
def guess_format(filename, options=_formats):
"""Select the best matching file type amongst supported formats."""
results = []
# Since we are reading only 128 bytes and then immediately closing the file,
# use unbuffered mode.
with open(filename, "rb", 0) as fileobj:
header = fileobj.read(128)
# Calls the score method of a particular format's associated filetype
# and assigns a positive score depending on how closely the fileobj's header matches
# the header for a particular file format.
results = [(option._File.score(filename, fileobj, header), option.__name__, option)
for option in options
if getattr(option, "_File", None)]
if results:
results.sort()
if results[-1][0] > 0:
# return the format with the highest matching score
return results[-1][2](filename)
# No positive score i.e. the fileobj's header did not match any supported format
return None
def open_(filename):
"""Open the specified file and return a File instance with the appropriate format handler, or None."""
try:
# Use extension based opening as default
i = filename.rfind(".")
if i >= 0:
ext = filename[i+1:].lower()
audio_file = _extensions[ext](filename)
else:
# If there is no extension, try to guess the format based on file headers
audio_file = guess_format(filename)
if not audio_file:
return None
return audio_file
except KeyError:
# None is returned if both the methods fail
return None
except Exception as error:
log.error("Error occurred:\n{}".format(error))
return None
register_format(AACFile)

98
picard/formats/util.py Normal file
View File

@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2006-2008, 2012 Lukáš Lalinský
# Copyright (C) 2008 Will
# Copyright (C) 2010, 2014, 2018-2020 Philipp Wolfer
# Copyright (C) 2013 Michael Wiencek
# Copyright (C) 2013, 2017-2019 Laurent Monin
# Copyright (C) 2016-2018 Sambhav Kothari
# Copyright (C) 2017 Sophist-UK
# Copyright (C) 2017 Ville Skyttä
#
# 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
# of the License, 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.
from picard import log
from picard.plugin import ExtensionPoint
_formats = ExtensionPoint(label='formats')
_extensions = {}
def register_format(file_format):
_formats.register(file_format.__module__, file_format)
for ext in file_format.EXTENSIONS:
_extensions[ext[1:]] = file_format
def supported_formats():
"""Returns list of supported formats."""
return [(file_format.EXTENSIONS, file_format.NAME) for file_format in _formats]
def supported_extensions():
"""Returns list of supported extensions."""
return [ext for exts, name in supported_formats() for ext in exts]
def ext_to_format(ext):
return _extensions.get(ext, None)
def guess_format(filename, options=_formats):
"""Select the best matching file type amongst supported formats."""
results = []
# Since we are reading only 128 bytes and then immediately closing the file,
# use unbuffered mode.
with open(filename, "rb", 0) as fileobj:
header = fileobj.read(128)
# Calls the score method of a particular format's associated filetype
# and assigns a positive score depending on how closely the fileobj's header matches
# the header for a particular file format.
results = [(option._File.score(filename, fileobj, header), option.__name__, option)
for option in options
if getattr(option, "_File", None)]
if results:
results.sort()
if results[-1][0] > 0:
# return the format with the highest matching score
return results[-1][2](filename)
# No positive score i.e. the fileobj's header did not match any supported format
return None
def open_(filename):
"""Open the specified file and return a File instance with the appropriate format handler, or None."""
try:
# Use extension based opening as default
i = filename.rfind(".")
if i >= 0:
ext = filename[i+1:].lower()
audio_file = _extensions[ext](filename)
else:
# If there is no extension, try to guess the format based on file headers
audio_file = guess_format(filename)
if not audio_file:
return None
return audio_file
except KeyError:
# None is returned if both the methods fail
return None
except Exception as error:
log.error("Error occurred:\n{}".format(error))
return None

View File

@@ -47,11 +47,11 @@ from picard.coverart.image import (
TagCoverArtImage,
)
from picard.file import File
from picard.formats import guess_format
from picard.formats.id3 import (
image_type_as_id3_num,
types_from_id3,
)
from picard.formats.util import guess_format
from picard.metadata import Metadata
from picard.util import (
encode_filename,

View File

@@ -34,6 +34,7 @@ from picard.formats import ext_to_format
from picard.formats.mutagenext.aac import AACAPEv2
from picard.formats.mutagenext.ac3 import AC3APEv2
from picard.formats.mutagenext.tak import TAK
from picard.formats.util import guess_format
from picard.metadata import Metadata
@@ -450,7 +451,7 @@ class CommonTests:
@skipUnlessTestfile
def test_guess_format(self):
temp_file = self.copy_of_original_testfile()
audio = picard.formats.guess_format(temp_file)
audio = guess_format(temp_file)
audio_original = picard.formats.open_(self.filename)
self.assertEqual(type(audio), type(audio_original))