diff --git a/.travis.yml b/.travis.yml index 127c0a93..b0caee80 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,11 @@ +dist: xenial language: python +cache: + pip: true python: - - "3.4" - "3.5" - "3.6" + - "3.7" +before_install: + - pip3 install picard script: python test.py diff --git a/generate.py b/generate.py index 590be619..836f5ca3 100755 --- a/generate.py +++ b/generate.py @@ -7,13 +7,13 @@ import zipfile from hashlib import md5 -from subprocess import call +from subprocess import check_call from get_plugin_data import get_plugin_data VERSION_TO_BRANCH = { - None: 'master', - '1.0': 'master', + None: '2.0', + '1.0': '1.0', '2.0': '2.0', } @@ -74,7 +74,8 @@ def zip_files(dest_dir): file_path = os.path.join(root, filename) plugin_files.append(file_path) - if len(plugin_files) == 1: + if (len(plugin_files) == 1 + and os.path.basename(plugin_files[0]) != '__init__.py'): # There's only one file, put it directly into the zipfile archive.write(plugin_files[0], os.path.basename(plugin_files[0]), @@ -106,12 +107,12 @@ def zip_files(dest_dir): parser.add_argument('--no-zip', action='store_false', dest='zip', help="Do not generate the zip files in the build output") parser.add_argument('--no-json', action='store_false', dest='json', help="Do not generate the json file in the build output") args = parser.parse_args() - call(["git", "checkout", "-q", VERSION_TO_BRANCH[args.version], '--', 'plugins']) + check_call(["git", "checkout", "-q", VERSION_TO_BRANCH[args.version], '--', 'plugins']) dest_dir = os.path.abspath(os.path.join(args.build_dir, args.version or '')) if not os.path.exists(dest_dir): os.makedirs(dest_dir) if args.pull: - call(["git", "pull", "-q"]) + check_call(["git", "pull", "-q"]) if args.json: build_json(dest_dir) if args.zip: diff --git a/plugins/abbreviate_artistsort/abbreviate_artistsort.py b/plugins/abbreviate_artistsort/abbreviate_artistsort.py index d15f9937..ba8a62d2 100644 --- a/plugins/abbreviate_artistsort/abbreviate_artistsort.py +++ b/plugins/abbreviate_artistsort/abbreviate_artistsort.py @@ -13,7 +13,6 @@ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. -from __future__ import print_function PLUGIN_NAME = "Abbreviate artist-sort" PLUGIN_AUTHOR = "Sophist" PLUGIN_DESCRIPTION = '''Abbreviate Artist-Sort and Album-Artist-Sort Tags. @@ -21,9 +20,9 @@ This is particularly useful for classical albums that can have a long list of artists. %artistsort% is abbreviated into %_artistsort_abbrev% and %albumartistsort% is abbreviated into %_albumartistsort_abbrev%.''' -PLUGIN_VERSION = "0.2" +PLUGIN_VERSION = "0.4" PLUGIN_API_VERSIONS = ["1.0", "2.0"] -PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" @@ -70,7 +69,6 @@ # Case c. If first word is same in sorted and unsorted, move words that match to new strings, then treat as b. # Case d. Try to handle without abbreviating and get to next name which might not be foreign -_debug_level = 0 _abbreviate_tags = [ ('albumartistsort', 'albumartist', '~albumartistsort_abbrev'), ('artistsort', 'artist', '~artistsort_abbrev'), @@ -90,11 +88,9 @@ def abbreviate_artistsort(tagger, metadata, track, release): unsorts = list(metadata.getall(unsortTag)) for i in range(0, min(len(sorts), len(unsorts))): sort = sorts[i] - if _debug_level > 1: - print("%s: Trying to abbreviate '%s'." % (PLUGIN_NAME, sort)) + log.debug("%s: Trying to abbreviate '%s'." % (PLUGIN_NAME, sort)) if sort in _abbreviate_cache: - if _debug_level > 3: - print(" Using abbreviation found in cache: '%s'." % (_abbreviate_cache[sort])) + log.debug(" Using abbreviation found in cache: '%s'." % (_abbreviate_cache[sort])) sorts[i] = _abbreviate_cache[sort] continue unsort = unsorts[i] @@ -104,8 +100,7 @@ def abbreviate_artistsort(tagger, metadata, track, release): while len(sort) > 0 and len(unsort) > 0: if not _split in sort: - if _debug_level > 3: - print(" Ending without separator '%s' - moving '%s'." % (_split, sort)) + log.debug(" Ending without separator '%s' - moving '%s'." % (_split, sort)) new_sort += sort new_unsort += unsort sort = unsort = "" @@ -113,8 +108,7 @@ def abbreviate_artistsort(tagger, metadata, track, release): surname, rest = sort.split(_split, 1) if rest == "": - if _debug_level > 3: - print(" Ending with separator '%s' - moving '%s'." % (_split, surname)) + log.debug(" Ending with separator '%s' - moving '%s'." % (_split, surname)) new_sort += sort new_unsort += unsort sort = unsort = "" @@ -129,8 +123,7 @@ def abbreviate_artistsort(tagger, metadata, track, release): temp = surname + _split l = len(temp) if unsort[:l] == temp: - if _debug_level > 3: - print(" No forename - moving '%s'." % (surname)) + log.debug(" No forename - moving '%s'." % (surname)) new_sort += temp new_unsort += temp sort = sort[l:] @@ -143,8 +136,7 @@ def abbreviate_artistsort(tagger, metadata, track, release): if unsort.find(' ' + surname) == -1: while surname.split(None, 1)[0] == unsort.split(None, 1)[0]: x = unsort.split(None, 1)[0] - if _debug_level > 3: - print(" Moving matching word '%s'." % (x)) + log.debug(" Moving matching word '%s'." % (x)) new_sort += x new_unsort += x surname = surname[len(x):] @@ -165,8 +157,7 @@ def abbreviate_artistsort(tagger, metadata, track, release): unsortTag, unsort[i], ) - if _debug_level > 0: - print(" Could not match surname (%s) in remaining unsorted:" % (surname, unsort)) + log.warning(" Could not match surname (%s) in remaining unsorted:" % (surname, unsort)) break # Sorted: Surname, Forename(s)... @@ -182,8 +173,7 @@ def abbreviate_artistsort(tagger, metadata, track, release): unsortTag, unsort[i], ) - if _debug_level > 0: - print(" Could not match forename (%s) for surname (%s) in remaining unsorted (%s):" % (forename, surname, unsort)) + log.warning(" Could not match forename (%s) for surname (%s) in remaining unsorted (%s):" % (forename, surname, unsort)) break inits = ' '.join([x[0] + '.' for x in forename.split()]) @@ -216,8 +206,7 @@ def abbreviate_artistsort(tagger, metadata, track, release): inits, sortTag, ) - if _debug_level > 2: - print("Abbreviated (%s, %s) to (%s, %s)." % (surname, forename, surname, inits)) + log.debug("Abbreviated (%s, %s) to (%s, %s)." % (surname, forename, surname, inits)) else: # while loop ended without a break i.e. no errors if unsorts[i] != new_unsort: log.error( @@ -228,13 +217,9 @@ def abbreviate_artistsort(tagger, metadata, track, release): unsorts[i], new_unsort, ) - if _debug_level > 0: - print() - print("Error: Unsorted text for %s has changed from '%s' to '%s'!" % (unsortTag, unsorts[i], new_unsort)) - print() + log.warning("Error: Unsorted text for %s has changed from '%s' to '%s'!" % (unsortTag, unsorts[i], new_unsort)) _abbreviate_cache[sorts[i]] = new_sort - if _debug_level > 1: - print(" Abbreviated and cached (%s) as (%s)." % (sorts[i], new_sort)) + log.debug(" Abbreviated and cached (%s) as (%s)." % (sorts[i], new_sort)) if sorts[i] != new_sort: log.debug(_("%s: Abbreviated tag '%s' to '%s'."), PLUGIN_NAME, diff --git a/plugins/acousticbrainz/acousticbrainz.py b/plugins/acousticbrainz/acousticbrainz.py index a5f36fcd..5e9a037f 100644 --- a/plugins/acousticbrainz/acousticbrainz.py +++ b/plugins/acousticbrainz/acousticbrainz.py @@ -23,7 +23,7 @@ WARNING: Experimental plugin. All guarantees voided by use.''' PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt" -PLUGIN_VERSION = "1.1" +PLUGIN_VERSION = "1.1.1" PLUGIN_API_VERSIONS = ["2.0"] from functools import partial @@ -39,19 +39,26 @@ def result(album, metadata, data, reply, error): + if error: + album._requests -= 1 + album._finalize_loading(None) + return + moods = [] genres = [] try: - data = load_json(data)["highlevel"] - for k, v in data.items(): - if k.startswith("genre_") and not v["value"].startswith("not_"): - genres.append(v["value"]) - if k.startswith("mood_") and not v["value"].startswith("not_"): - moods.append(v["value"]) + data = load_json(data) + if "highlevel" in data: + data = data["highlevel"] + for k, v in data.items(): + if k.startswith("genre_") and not v["value"].startswith("not_"): + genres.append(v["value"]) + if k.startswith("mood_") and not v["value"].startswith("not_"): + moods.append(v["value"]) - metadata["genre"] = genres - metadata["mood"] = moods - log.debug("%s: Track %s (%s) Parsed response (genres: %s, moods: %s)", PLUGIN_NAME, metadata["musicbrainz_recordingid"], metadata["title"], str(genres), str(moods)) + metadata["genre"] = genres + metadata["mood"] = moods + log.debug("%s: Track %s (%s) Parsed response (genres: %s, moods: %s)", PLUGIN_NAME, metadata["musicbrainz_recordingid"], metadata["title"], str(genres), str(moods)) except Exception as e: log.error("%s: Track %s (%s) Error parsing response: %s", PLUGIN_NAME, metadata["musicbrainz_recordingid"], metadata["title"], str(e)) finally: @@ -60,12 +67,13 @@ def result(album, metadata, data, reply, error): def process_track(album, metadata, release, track): - album.tagger.webservice.download( + album.tagger.webservice.get( ACOUSTICBRAINZ_HOST, ACOUSTICBRAINZ_PORT, "/%s/high-level" % (metadata["musicbrainz_recordingid"]), partial(result, album, metadata), - priority=True + priority=True, + parse_response_type=None ) album._requests += 1 diff --git a/plugins/acousticbrainz_tonal-rhythm/acousticbrainz_tonal-rhythm.py b/plugins/acousticbrainz_tonal-rhythm/acousticbrainz_tonal-rhythm.py index 82ffd77a..648b4443 100644 --- a/plugins/acousticbrainz_tonal-rhythm/acousticbrainz_tonal-rhythm.py +++ b/plugins/acousticbrainz_tonal-rhythm/acousticbrainz_tonal-rhythm.py @@ -25,15 +25,18 @@ from the AcousticBrainz database.

''' -PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt" -PLUGIN_VERSION = '1.1' +PLUGIN_VERSION = '1.1.2' PLUGIN_API_VERSIONS = ["2.0"] # Requires support for TKEY which is in 1.4 +from json import JSONDecodeError + from picard import log from picard.metadata import register_track_metadata_processor from functools import partial from picard.webservice import ratecontrol +from picard.util import load_json ACOUSTICBRAINZ_HOST = "acousticbrainz.org" ACOUSTICBRAINZ_PORT = 80 @@ -44,9 +47,14 @@ class AcousticBrainz_Key: def get_data(self, album, track_metadata, trackXmlNode, releaseXmlNode): + if "musicbrainz_recordingid" not in track_metadata: + log.error("%s: Error parsing response. No MusicBrainz recording id found.", + PLUGIN_NAME) + return recordingId = track_metadata['musicbrainz_recordingid'] if recordingId: - log.debug("%s: Add AcusticBrainz request for %s (%s)", PLUGIN_NAME, track_metadata['title'], recordingId) + log.debug("%s: Add AcousticBrainz request for %s (%s)", + PLUGIN_NAME, track_metadata['title'], recordingId) self.album_add_request(album) path = "/%s/low-level" % recordingId return album.tagger.webservice.get( @@ -54,16 +62,23 @@ def get_data(self, album, track_metadata, trackXmlNode, releaseXmlNode): ACOUSTICBRAINZ_PORT, path, partial(self.process_data, album, track_metadata), - priority=True, important=False) - return + priority=True, + important=False, + parse_response_type=None) def process_data(self, album, track_metadata, response, reply, error): if error: log.error("%s: Network error retrieving acousticBrainz data for recordingId %s", - PLUGIN_NAME, track_metadata['musicbrainz_recordingid']) + PLUGIN_NAME, track_metadata['musicbrainz_recordingid']) + self.album_remove_request(album) + return + try: + data = load_json(response) + except JSONDecodeError: + log.error("%s: Network error retrieving AcousticBrainz data for recordingId %s", + PLUGIN_NAME, track_metadata['musicbrainz_recordingid']) self.album_remove_request(album) return - data = response if "tonal" in data: if "key_key" in data["tonal"]: key = data["tonal"]["key_key"] @@ -88,4 +103,5 @@ def album_remove_request(self, album): if album._requests == 0: album._finalize_loading(None) + register_track_metadata_processor(AcousticBrainz_Key().get_data) diff --git a/plugins/add_album_column/__init__.py b/plugins/add_album_column/__init__.py new file mode 100644 index 00000000..f74a7bb6 --- /dev/null +++ b/plugins/add_album_column/__init__.py @@ -0,0 +1,51 @@ +# -*- coding: UTF-8 -*- + +# +# Licensing +# +# Add Album Column, Add the Album column to the main window panel +# Copyright (C) 2019 Evandro Coan +# +# Redistributions of source code must retain the above +# copyright notice, this list of conditions and the +# following disclaimer. +# +# Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials +# provided with the distribution. +# +# Neither the name Evandro Coan nor the names of any +# contributors may be used to endorse or promote products +# derived from this software without specific prior written +# permission. +# +# 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 3 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, see . +# + +PLUGIN_NAME = u"Add Album Column" +PLUGIN_AUTHOR = u"Evandro Coan" +PLUGIN_DESCRIPTION = """Add the Album column to the main window panel. + +WARNING: This plugin cannot be disabled. See: +https://github.com/metabrainz/picard-plugins/pull/195 +""" + +PLUGIN_VERSION = "1.0" +PLUGIN_API_VERSIONS = ["2.0"] +PLUGIN_LICENSE = "GPL-3.0-or-later" +PLUGIN_LICENSE_URL = "http://www.gnu.org/licenses/" + +from picard.ui.itemviews import MainPanel +MainPanel.columns.append((N_('Album'), 'album')) diff --git a/plugins/addrelease/addrelease.py b/plugins/addrelease/addrelease.py index b00fdd35..12f538bd 100644 --- a/plugins/addrelease/addrelease.py +++ b/plugins/addrelease/addrelease.py @@ -6,10 +6,10 @@ files to help you quickly add them as releases or standalone recordings to\ the MusicBrainz database via the website by pre-populating artists,\ track names and times." -PLUGIN_VERSION = "0.7.2" +PLUGIN_VERSION = "0.7.3" PLUGIN_API_VERSIONS = ["2.0"] -from picard import config +from picard import config, log from picard.cluster import Cluster from picard.const import MUSICBRAINZ_SERVERS from picard.file import File @@ -116,38 +116,83 @@ class AddClusterAsRelease(AddObjectAsEntity): objtype = Cluster submit_path = '/release/add' + def __init__(self): + super().__init__() + self.discnumber_shift = -1 + + def extract_discnumber(self, metadata): + """ + >>> from picard.metadata import Metadata + >>> m = Metadata() + >>> AddClusterAsRelease().extract_discnumber(m) + 0 + >>> m["discnumber"] = "boop" + >>> AddClusterAsRelease().extract_discnumber(m) + 0 + >>> m["discnumber"] = "1" + >>> AddClusterAsRelease().extract_discnumber(m) + 0 + >>> m["discnumber"] = 1 + >>> AddClusterAsRelease().extract_discnumber(m) + 0 + >>> m["discnumber"] = -1 + >>> AddClusterAsRelease().extract_discnumber(m) + 0 + >>> m["discnumber"] = "1/1" + >>> AddClusterAsRelease().extract_discnumber(m) + 0 + >>> m["discnumber"] = "2/2" + >>> AddClusterAsRelease().extract_discnumber(m) + 1 + >>> a = AddClusterAsRelease() + >>> m["discnumber"] = "-2/2" + >>> a.extract_discnumber(m) + 0 + >>> m["discnumber"] = "-1/4" + >>> a.extract_discnumber(m) + 1 + >>> m["discnumber"] = "1/4" + >>> a.extract_discnumber(m) + 3 + + """ + # As per https://musicbrainz.org/doc/Development/Release_Editor_Seeding#Tracklists_data + # the medium numbers ("m") must be starting with 0. + # Maybe the existing tags don't have disc numbers in them or + # they're starting with something smaller than or equal to 0, so try + # to produce a sane disc number. + try: + discnumber = metadata.get("discnumber", "1") + # Split off any totaldiscs information + discnumber = discnumber.split("/", 1)[0] + m = int(discnumber) + if m <= 0: + # A disc number was smaller than or equal to 0 - all other + # disc numbers need to be changed to accommodate that. + self.discnumber_shift = max(self.discnumber_shift, 0 - m) + m = m + self.discnumber_shift + except ValueError as e: + # The most likely reason for an exception at this point is because + # the disc number in the tags was not a number. Just log the + # exception and assume the medium number is 0. + log.info("Trying to get the disc number of %s caused the following error: %s; assuming 0", + metadata["~filename"], e) + m = 0 + return m + def set_form_values(self, cluster): nv = self.add_form_value nv("artist_credit.names.0.artist.name", cluster.metadata["albumartist"]) nv("name", cluster.metadata["album"]) - discnumber_shift = -1 for i, file in enumerate(cluster.files): try: i = int(file.metadata["tracknumber"]) - 1 except: pass - # As per https://musicbrainz.org/doc/Development/Release_Editor_Seeding#Tracklists_data - # the medium numbers ("m") must be starting with 0. - # Maybe the existing tags don't have disc numbers in them or - # they're starting with something smaller than or equal to 0, so try - # to produce a sane disc number. - try: - m = int(file.metadata.get("discnumber", 1)) - if m <= 0: - # A disc number was smaller than or equal to 0 - all other - # disc numbers need to be changed to accommodate that. - discnumber_shift = max(discnumber_shift, 0 - m) - m = m + discnumber_shift - except Exception as e: - # The most likely reason for an exception at this point is a - # ValueError because the disc number in the tags was not a - # number. Just log the exception and assume the medium number - # is 0. - file.log.info("Trying to get the disc number of %s caused the following error: %s; assuming 0", - file.filename, e) - m = 0 + + m = self.extract_discnumber(file.metadata) # add a track-level name-value def tnv(n, v): @@ -198,3 +243,7 @@ def set_form_values(self, track): register_cluster_action(AddClusterAsRelease()) register_file_action(AddFileAsRecording()) register_file_action(AddFileAsRelease()) + +if __name__ == "__main__": + import doctest + doctest.testmod() diff --git a/plugins/albumartistextension/albumartistextension.py b/plugins/albumartistextension/albumartistextension.py index 68da5250..0bb7727d 100644 --- a/plugins/albumartistextension/albumartistextension.py +++ b/plugins/albumartistextension/albumartistextension.py @@ -25,7 +25,7 @@ PLUGIN_VERSION = "0.6" PLUGIN_API_VERSIONS = ["2.0"] -PLUGIN_LICENSE = "GPL-2.0 or later" +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from picard import config, log diff --git a/plugins/amazon/amazon.py b/plugins/amazon/amazon.py new file mode 100644 index 00000000..d2f0b90b --- /dev/null +++ b/plugins/amazon/amazon.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# Copyright (C) 2007 Oliver Charles +# Copyright (C) 2007-2011, 2019 Philipp Wolfer +# Copyright (C) 2007, 2010, 2011 Lukáš Lalinský +# Copyright (C) 2011 Michael Wiencek +# Copyright (C) 2011-2012 Wieland Hoffmann +# Copyright (C) 2013-2016 Laurent Monin +# +# 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. + +PLUGIN_NAME = 'Amazon cover art' +PLUGIN_AUTHOR = 'MusicBrainz Picard developers' +PLUGIN_DESCRIPTION = 'Use cover art from Amazon.' +PLUGIN_VERSION = "1.0" +PLUGIN_API_VERSIONS = ["2.2"] +PLUGIN_LICENSE = "GPL-2.0-or-later" +PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" + +from picard import log +from picard.coverart.image import CoverArtImage +from picard.coverart.providers import ( + CoverArtProvider, + register_cover_art_provider, +) +from picard.util import parse_amazon_url + +# amazon image file names are unique on all servers and constructed like +# ..[SML]ZZZZZZZ.jpg +# A release sold on amazon.de has always = 03, for example. +# Releases not sold on amazon.com, don't have a "01"-version of the image, +# so we need to make sure we grab an existing image. +AMAZON_SERVER = { + "amazon.jp": { + "server": "ec1.images-amazon.com", + "id": "09", + }, + "amazon.co.jp": { + "server": "ec1.images-amazon.com", + "id": "09", + }, + "amazon.co.uk": { + "server": "ec1.images-amazon.com", + "id": "02", + }, + "amazon.de": { + "server": "ec2.images-amazon.com", + "id": "03", + }, + "amazon.com": { + "server": "ec1.images-amazon.com", + "id": "01", + }, + "amazon.ca": { + "server": "ec1.images-amazon.com", + "id": "01", # .com and .ca are identical + }, + "amazon.fr": { + "server": "ec1.images-amazon.com", + "id": "08" + }, +} + +AMAZON_IMAGE_PATH = '/images/P/%(asin)s.%(serverid)s.%(size)s.jpg' + +# First item in the list will be tried first +AMAZON_SIZES = ( + # huge size option is only available for items + # that have a ZOOMing picture on its amazon web page + # and it doesn't work for all of the domain names + # '_SCRM_', # huge size + 'LZZZZZZZ', # large size, option format 1 + # '_SCLZZZZZZZ_', # large size, option format 3 + 'MZZZZZZZ', # default image size, format 1 + # '_SCMZZZZZZZ_', # medium size, option format 3 + # 'TZZZZZZZ', # medium image size, option format 1 + # '_SCTZZZZZZZ_', # small size, option format 3 + # 'THUMBZZZ', # small size, option format 1 +) + + +class CoverArtProviderAmazon(CoverArtProvider): + + """Use Amazon ASIN Musicbrainz relationships to get cover art""" + + NAME = "Amazon" + TITLE = N_('Amazon') + + def enabled(self): + return (super().enabled() + and not self.coverart.front_image_found) + + def queue_images(self): + self.match_url_relations(('amazon asin', 'has_Amazon_ASIN'), + self._queue_from_asin_relation) + return CoverArtProvider.FINISHED + + def _queue_from_asin_relation(self, url): + """Queue cover art images from Amazon""" + amz = parse_amazon_url(url) + if amz is None: + return + log.debug("Found ASIN relation : %s %s", amz['host'], amz['asin']) + if amz['host'] in AMAZON_SERVER: + serverInfo = AMAZON_SERVER[amz['host']] + else: + serverInfo = AMAZON_SERVER['amazon.com'] + host = serverInfo['server'] + for size in AMAZON_SIZES: + path = AMAZON_IMAGE_PATH % { + 'asin': amz['asin'], + 'serverid': serverInfo['id'], + 'size': size + } + self.queue_put(CoverArtImage("http://%s%s" % (host, path))) + + +register_cover_art_provider(CoverArtProviderAmazon) diff --git a/plugins/apiseeds_lyrics/apiseeds_lyrics.py b/plugins/apiseeds_lyrics/apiseeds_lyrics.py new file mode 100644 index 00000000..bc7235bc --- /dev/null +++ b/plugins/apiseeds_lyrics/apiseeds_lyrics.py @@ -0,0 +1,123 @@ +from functools import partial +from urllib.parse import quote, urlencode + +from PyQt5 import QtWidgets +from picard import config, log +from picard.config import TextOption +from picard.metadata import register_track_metadata_processor +from picard.ui.options import register_options_page, OptionsPage +from picard.webservice import ratecontrol + +PLUGIN_NAME = 'Apiseeds Lyrics' +PLUGIN_AUTHOR = 'Andrea Avallone' +PLUGIN_DESCRIPTION = 'Fetch lyrics from Apiseeds Lyrics, which provides millions of lyrics from artist all around the world. ' \ + 'Lyrics provided are for educational purposes and personal use only. Commercial use is not allowed. ' \ + 'In order to use Apiseeds you need to get a free API key at https://apiseeds.com. ' \ + 'Want to contribute? Check out the project page at https://github.com/avalloneandrea/apiseeds-lyrics!' +PLUGIN_VERSION = '1.0.4' +PLUGIN_API_VERSIONS = ['2.0'] +PLUGIN_LICENSE = 'MIT' +PLUGIN_LICENSE_URL = 'https://opensource.org/licenses/MIT' + + +class ApiseedsLyricsMetadataProcessor(object): + + apiseeds_host = 'orion.apiseeds.com' + apiseeds_port = 443 + apiseeds_delay = 60 * 1000 / 200 # 200 requests per minute + + def __init__(self): + super(ApiseedsLyricsMetadataProcessor, self).__init__() + ratecontrol.set_minimum_delay((self.apiseeds_host, self.apiseeds_port), self.apiseeds_delay) + + def process_metadata(self, album, metadata, track, release): + + apikey = config.setting['apiseeds_apikey'] + if not apikey: + log.debug('{}: API key is missing, please provide a valid value'.format(PLUGIN_NAME)) + return + + artist = metadata['artist'] + if not artist: + log.debug('{}: artist is missing, please provide a valid value'.format(PLUGIN_NAME)) + return + + title = metadata['title'] + if not title: + log.debug('{}: title is missing, please provide a valid value'.format(PLUGIN_NAME)) + return + + apiseeds_path = '/api/music/lyric/{}/{}'.format(quote(artist, ''), quote(title, '')) + apiseeds_params = {'apikey': apikey} + album._requests += 1 + log.debug('{}: GET {}?{}'.format(PLUGIN_NAME, quote(apiseeds_path), urlencode(apiseeds_params))) + + album.tagger.webservice.get( + self.apiseeds_host, + self.apiseeds_port, + apiseeds_path, + partial(self.process_response, album, metadata), + parse_response_type='json', + priority=True, + queryargs=apiseeds_params) + + @staticmethod + def process_response(album, metadata, response, reply, error): + + try: + lyrics = response['result']['track']['text'] + metadata['lyrics'] = lyrics + log.debug('{}: lyrics found for track {}'.format(PLUGIN_NAME, metadata['title'])) + + except (TypeError, KeyError): + log.debug('{}: lyrics NOT found for track {}'.format(PLUGIN_NAME, metadata['title'])) + + finally: + album._requests -= 1 + album._finalize_loading(None) + + +class ApiseedsLyricsOptionsPage(OptionsPage): + + NAME = 'apiseeds_lyrics' + TITLE = 'Apiseeds Lyrics' + PARENT = 'plugins' + + options = [TextOption('setting', 'apiseeds_apikey', '')] + + def __init__(self, parent=None): + + super(ApiseedsLyricsOptionsPage, self).__init__(parent) + self.box = QtWidgets.QVBoxLayout(self) + + self.label = QtWidgets.QLabel(self) + self.label.setText('Apiseeds API key') + self.box.addWidget(self.label) + + self.description = QtWidgets.QLabel(self) + self.description.setText('Apiseeds Lyrics provides millions of lyrics from artist all around the world. ' + 'Lyrics provided are for educational purposes and personal use only. Commercial use is not allowed. ' + 'In order to use Apiseeds Lyrics you need to get a free API key here.') + self.description.setOpenExternalLinks(True) + self.box.addWidget(self.description) + + self.input = QtWidgets.QLineEdit(self) + self.box.addWidget(self.input) + + self.contribute = QtWidgets.QLabel(self) + self.contribute.setText('Want to contribute? Check out the project page!') + self.contribute.setOpenExternalLinks(True) + self.box.addWidget(self.contribute) + + self.spacer = QtWidgets.QSpacerItem(0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.box.addItem(self.spacer) + + def load(self): + self.input.setText(config.setting['apiseeds_apikey']) + + def save(self): + config.setting['apiseeds_apikey'] = self.input.text() + + +register_track_metadata_processor(ApiseedsLyricsMetadataProcessor().process_metadata) +register_options_page(ApiseedsLyricsOptionsPage) diff --git a/plugins/bpm/__init__.py b/plugins/bpm/__init__.py index c4529182..4f319285 100644 --- a/plugins/bpm/__init__.py +++ b/plugins/bpm/__init__.py @@ -8,11 +8,11 @@ # PLUGIN_NAME = "BPM Analyzer" -PLUGIN_AUTHOR = "Len Joubert, Sambhav Kothari" +PLUGIN_AUTHOR = "Len Joubert, Sambhav Kothari, Philipp Wolfer" PLUGIN_DESCRIPTION = """Calculate BPM for selected files and albums. Linux only version with dependancy on Aubio and Numpy""" PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" -PLUGIN_VERSION = "1.1" +PLUGIN_VERSION = "1.4" PLUGIN_API_VERSIONS = ["2.0"] # PLUGIN_INCOMPATIBLE_PLATFORMS = [ # 'win32', 'cygwyn', 'darwin', 'os2', 'os2emx', 'riscos', 'atheos'] @@ -40,42 +40,17 @@ } -def get_file_bpm(self, path): - """ Calculate the beats per minute (bpm) of a given file. - path: path to the file - buf_size length of FFT - hop_size number of frames between two consecutive runs - samplerate sampling rate of the signal to analyze - """ - - samplerate, buf_size, hop_size = bpm_slider_settings[ - BPMOptionsPage.config.setting["bpm_slider_parameter"]] - mediasource = source(path, samplerate, hop_size) - samplerate = mediasource.samplerate - beattracking = tempo("specdiff", buf_size, hop_size, samplerate) - # List of beats, in samples - beats = [] - # Total number of frames read - total_frames = 0 - - while True: - samples, read = mediasource() - is_beat = beattracking(samples) - if is_beat: - this_beat = beattracking.get_last_s() - beats.append(this_beat) - total_frames += read - if read < hop_size: - break - - # Convert to periods and to bpm - bpms = 60. / diff(beats) - return median(bpms) - - class FileBPM(BaseAction): NAME = N_("Calculate BPM...") + def __init__(self): + super().__init__() + self._close = False + self.tagger.aboutToQuit.connect(self._cleanup) + + def _cleanup(self): + self._close = True + def _add_file_to_queue(self, file): thread.run_task( partial(self._calculate_bpm, file), @@ -94,9 +69,12 @@ def _calculate_bpm(self, file): N_('Calculating BPM for "%(filename)s"...'), {'filename': file.filename} ) - calculated_bpm = get_file_bpm(self.tagger, file.filename) + calculated_bpm = self._get_file_bpm(file.filename) # self.tagger.log.debug('%s' % (calculated_bpm)) - file.metadata["bpm"] = string_(round(calculated_bpm, 1)) + if self._close: + return + file.metadata["bpm"] = str(round(calculated_bpm, 1)) + file.update() def _calculate_bpm_callback(self, file, result=None, error=None): if not error: @@ -110,6 +88,40 @@ def _calculate_bpm_callback(self, file, result=None, error=None): {'filename': file.filename} ) + def _get_file_bpm(self, path): + """ Calculate the beats per minute (bpm) of a given file. + path: path to the file + buf_size length of FFT + hop_size number of frames between two consecutive runs + samplerate sampling rate of the signal to analyze + """ + + samplerate, buf_size, hop_size = bpm_slider_settings[ + BPMOptionsPage.config.setting["bpm_slider_parameter"]] + mediasource = source(path, samplerate, hop_size) + samplerate = mediasource.samplerate + beattracking = tempo("specdiff", buf_size, hop_size, samplerate) + # List of beats, in samples + beats = [] + # Total number of frames read + total_frames = 0 + + while True: + if self._close: + return + samples, read = mediasource() + is_beat = beattracking(samples) + if is_beat: + this_beat = beattracking.get_last_s() + beats.append(this_beat) + total_frames += read + if read < hop_size: + break + + # Convert to periods and to bpm + bpms = 60. / diff(beats) + return median(bpms) + class BPMOptionsPage(OptionsPage): @@ -127,6 +139,7 @@ def __init__(self, parent=None): self.ui = Ui_BPMOptionsPage() self.ui.setupUi(self) self.ui.slider_parameter.valueChanged.connect(self.update_parameters) + self.update_parameters() def load(self): cfg = self.config.setting @@ -138,7 +151,7 @@ def save(self): def update_parameters(self): val = self.ui.slider_parameter.value() - samplerate, buf_size, hop_size = [string_(v) for v in + samplerate, buf_size, hop_size = [str(v) for v in bpm_slider_settings[val]] self.ui.samplerate_value.setText(samplerate) self.ui.win_s_value.setText(buf_size) diff --git a/plugins/bpm/options_bpm.ui b/plugins/bpm/options_bpm.ui index 418a7b74..e112015d 100644 --- a/plugins/bpm/options_bpm.ui +++ b/plugins/bpm/options_bpm.ui @@ -6,149 +6,160 @@ 0 0 - 495 + 611 273 - - + + BPM Analyze Parameters: - - - - 8 - 80 - 43 - 19 - - - - - - - Default - - - - - - 20 - 51 - 391 - 23 - - - - 1 - - - 3 - - - Qt::Horizontal - - - QSlider::TicksBelow - - - 1 - - - - - - 370 - 80 - 61 - 19 - - - - Qt::RightToLeft - - - Super Fast - - - - - - 10 - 120 - 471 - 20 - - - - Qt::Horizontal - - - - - - 6 - 140 - 471 - 91 - - - - - - - - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Number of frames between two consecutive runs: - - - - - - - - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Length of FFT: - - - - - - - Samplerate: - - - - - + + + + + + 500 + 16777215 + + + + + + + 1 + + + 3 + + + 1 + + + Qt::Horizontal + + + QSlider::TicksBelow + + + 1 + + + + + + + 6 + + + + + Qt::RightToLeft + + + Super Fast + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + Default + + + Qt::AlignRight|Qt::AlignTop|Qt::AlignTrailing + + + + + + + + + Qt::Horizontal + + + + + + + + + Samplerate: + + + + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Number of frames between two consecutive runs: + + + + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Length of FFT: + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + diff --git a/plugins/bpm/ui_options_bpm.py b/plugins/bpm/ui_options_bpm.py index feca3fc3..8808f856 100644 --- a/plugins/bpm/ui_options_bpm.py +++ b/plugins/bpm/ui_options_bpm.py @@ -1,82 +1,98 @@ # -*- coding: utf-8 -*- -# Automatically generated - don't edit. -# Use `python setup.py build_ui` to update it. +# Form implementation generated from reading ui file 'options_bpm.ui' +# +# Created by: PyQt5 UI code generator 5.11.3 +# +# WARNING! All changes made in this file will be lost! from PyQt5 import QtCore, QtGui, QtWidgets - class Ui_BPMOptionsPage(object): def setupUi(self, BPMOptionsPage): BPMOptionsPage.setObjectName("BPMOptionsPage") - BPMOptionsPage.resize(495, 273) - self.gridLayout_2 = QtWidgets.QGridLayout(BPMOptionsPage) - self.gridLayout_2.setObjectName("gridLayout_2") + BPMOptionsPage.resize(611, 273) + self.verticalLayout = QtWidgets.QVBoxLayout(BPMOptionsPage) + self.verticalLayout.setObjectName("verticalLayout") self.bpm_options = QtWidgets.QGroupBox(BPMOptionsPage) self.bpm_options.setObjectName("bpm_options") - self.slider_default = QtWidgets.QLabel(self.bpm_options) - self.slider_default.setGeometry(QtCore.QRect(8, 80, 43, 19)) - self.slider_default.setToolTip("") - self.slider_default.setObjectName("slider_default") - self.slider_parameter = QtWidgets.QSlider(self.bpm_options) - self.slider_parameter.setGeometry(QtCore.QRect(20, 51, 391, 23)) + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.bpm_options) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.verticalWidget = QtWidgets.QWidget(self.bpm_options) + self.verticalWidget.setMaximumSize(QtCore.QSize(500, 16777215)) + self.verticalWidget.setObjectName("verticalWidget") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.verticalWidget) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.slider_parameter = QtWidgets.QSlider(self.verticalWidget) self.slider_parameter.setMinimum(1) self.slider_parameter.setMaximum(3) + self.slider_parameter.setPageStep(1) self.slider_parameter.setOrientation(QtCore.Qt.Horizontal) self.slider_parameter.setTickPosition(QtWidgets.QSlider.TicksBelow) self.slider_parameter.setTickInterval(1) self.slider_parameter.setObjectName("slider_parameter") - self.slider_super_fast = QtWidgets.QLabel(self.bpm_options) - self.slider_super_fast.setGeometry(QtCore.QRect(370, 80, 61, 19)) + self.verticalLayout_3.addWidget(self.slider_parameter) + self.slider_labels = QtWidgets.QHBoxLayout() + self.slider_labels.setSpacing(6) + self.slider_labels.setObjectName("slider_labels") + self.slider_super_fast = QtWidgets.QLabel(self.verticalWidget) self.slider_super_fast.setLayoutDirection(QtCore.Qt.RightToLeft) + self.slider_super_fast.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) self.slider_super_fast.setObjectName("slider_super_fast") - self.line = QtWidgets.QFrame(self.bpm_options) - self.line.setGeometry(QtCore.QRect(10, 120, 471, 20)) + self.slider_labels.addWidget(self.slider_super_fast) + self.slider_default = QtWidgets.QLabel(self.verticalWidget) + self.slider_default.setToolTip("") + self.slider_default.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTop|QtCore.Qt.AlignTrailing) + self.slider_default.setObjectName("slider_default") + self.slider_labels.addWidget(self.slider_default) + self.verticalLayout_3.addLayout(self.slider_labels) + self.line = QtWidgets.QFrame(self.verticalWidget) self.line.setFrameShape(QtWidgets.QFrame.HLine) self.line.setFrameShadow(QtWidgets.QFrame.Sunken) self.line.setObjectName("line") - self.horizontalLayoutWidget = QtWidgets.QWidget(self.bpm_options) - self.horizontalLayoutWidget.setGeometry(QtCore.QRect(6, 140, 471, 91)) - self.horizontalLayoutWidget.setObjectName("horizontalLayoutWidget") - self.gridLayout = QtWidgets.QGridLayout(self.horizontalLayoutWidget) - self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_3.addWidget(self.line) + self.gridLayout = QtWidgets.QGridLayout() self.gridLayout.setObjectName("gridLayout") - self.samplerate_value = QtWidgets.QLabel(self.horizontalLayoutWidget) + self.samplerate_label = QtWidgets.QLabel(self.verticalWidget) + self.samplerate_label.setObjectName("samplerate_label") + self.gridLayout.addWidget(self.samplerate_label, 2, 0, 1, 1) + self.samplerate_value = QtWidgets.QLabel(self.verticalWidget) self.samplerate_value.setText("") self.samplerate_value.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) self.samplerate_value.setObjectName("samplerate_value") self.gridLayout.addWidget(self.samplerate_value, 2, 1, 1, 1) - self.hop_s_label = QtWidgets.QLabel(self.horizontalLayoutWidget) + self.hop_s_value = QtWidgets.QLabel(self.verticalWidget) + self.hop_s_value.setText("") + self.hop_s_value.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.hop_s_value.setObjectName("hop_s_value") + self.gridLayout.addWidget(self.hop_s_value, 1, 1, 1, 1) + self.hop_s_label = QtWidgets.QLabel(self.verticalWidget) self.hop_s_label.setObjectName("hop_s_label") self.gridLayout.addWidget(self.hop_s_label, 1, 0, 1, 1) - self.win_s_value = QtWidgets.QLabel(self.horizontalLayoutWidget) + self.win_s_value = QtWidgets.QLabel(self.verticalWidget) self.win_s_value.setText("") self.win_s_value.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) self.win_s_value.setObjectName("win_s_value") self.gridLayout.addWidget(self.win_s_value, 0, 1, 1, 1) - self.hop_s_value = QtWidgets.QLabel(self.horizontalLayoutWidget) - self.hop_s_value.setText("") - self.hop_s_value.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.hop_s_value.setObjectName("hop_s_value") - self.gridLayout.addWidget(self.hop_s_value, 1, 1, 1, 1) - self.win_s_label = QtWidgets.QLabel(self.horizontalLayoutWidget) + self.win_s_label = QtWidgets.QLabel(self.verticalWidget) self.win_s_label.setObjectName("win_s_label") self.gridLayout.addWidget(self.win_s_label, 0, 0, 1, 1) - self.samplerate_label = QtWidgets.QLabel(self.horizontalLayoutWidget) - self.samplerate_label.setObjectName("samplerate_label") - self.gridLayout.addWidget(self.samplerate_label, 2, 0, 1, 1) - self.gridLayout.setColumnStretch(0, 1) - self.gridLayout.setColumnStretch(1, 1) - self.gridLayout_2.addWidget(self.bpm_options, 0, 0, 1, 1) + self.gridLayout.setColumnStretch(0, 4) + self.verticalLayout_3.addLayout(self.gridLayout) + self.verticalLayout_2.addWidget(self.verticalWidget) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_2.addItem(spacerItem) + self.verticalLayout.addWidget(self.bpm_options) self.retranslateUi(BPMOptionsPage) QtCore.QMetaObject.connectSlotsByName(BPMOptionsPage) def retranslateUi(self, BPMOptionsPage): _translate = QtCore.QCoreApplication.translate - self.bpm_options.setTitle(_("BPM Analyze Parameters:")) - self.slider_default.setText(_("Default")) - self.slider_super_fast.setText(_("Super Fast")) - self.hop_s_label.setText(_("Number of frames between two consecutive runs:")) - self.win_s_label.setText(_("Length of FFT:")) - self.samplerate_label.setText(_("Samplerate:")) + self.bpm_options.setTitle(_translate("BPMOptionsPage", "BPM Analyze Parameters:")) + self.slider_super_fast.setText(_translate("BPMOptionsPage", "Super Fast")) + self.slider_default.setText(_translate("BPMOptionsPage", "Default")) + self.samplerate_label.setText(_translate("BPMOptionsPage", "Samplerate:")) + self.hop_s_label.setText(_translate("BPMOptionsPage", "Number of frames between two consecutive runs:")) + self.win_s_label.setText(_translate("BPMOptionsPage", "Length of FFT:")) + diff --git a/plugins/compatible_TXXX/compatible_TXXX.py b/plugins/compatible_TXXX/compatible_TXXX.py new file mode 100644 index 00000000..fd49088c --- /dev/null +++ b/plugins/compatible_TXXX/compatible_TXXX.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +PLUGIN_NAME = u"Compatible TXXX frames" +PLUGIN_AUTHOR = u'Tungol' +PLUGIN_DESCRIPTION = """This plugin improves the compatibility of ID3 tags \ +by using only a single value for TXXX frames. Multiple value TXXX frames \ +technically don't comply with the ID3 specification.""" +PLUGIN_VERSION = "0.1" +PLUGIN_API_VERSIONS = ["2.0"] +PLUGIN_LICENSE = "GPL-2.0-or-later" +PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" + +from picard import config +from picard.formats import register_format +from picard.formats.id3 import MP3File, TrueAudioFile, DSFFile, AiffFile +from mutagen import id3 + + +id3v24_join_with = '; ' + + +def build_compliant_TXXX(self, encoding, desc, values): + """Return a TXXX frame with only a single value. + + Use id3v23_join_with as the sperator if using id3v2.3, otherwise the value + set in this plugin (default "; "). + """ + if config.setting['write_id3v23']: + sep = config.setting['id3v23_join_with'] + else: + sep = id3v24_join_with + joined_values = [sep.join(values)] + return id3.TXXX(encoding=encoding, desc=desc, text=joined_values) + + +# I can't actually remove the original MP3File et al formats once they're +# registered. This depends on the name of the replacements sorting after the +# name of the originals, because picard.formats.guess_format picks the last +# item from a sorted list. + + +class MP3FileCompliant(MP3File): + """Alternate MP3 format class which uses single-value TXXX frames.""" + + build_TXXX = build_compliant_TXXX + + +class TrueAudioFileCompliant(TrueAudioFile): + """Alternate TTA format class which uses single-value TXXX frames.""" + + build_TXXX = build_compliant_TXXX + + +class DSFFileCompliant(DSFFile): + """Alternate DSF format class which uses single-value TXXX frames.""" + + build_TXXX = build_compliant_TXXX + + +class AiffFileCompliant(AiffFile): + """Alternate AIFF format class which uses single-value TXXX frames.""" + + build_TXXX = build_compliant_TXXX + + +register_format(MP3FileCompliant) +register_format(TrueAudioFileCompliant) +register_format(DSFFileCompliant) +register_format(AiffFileCompliant) diff --git a/plugins/cuesheet/cuesheet.py b/plugins/cuesheet/cuesheet.py index b7f73aa7..c95351f2 100644 --- a/plugins/cuesheet/cuesheet.py +++ b/plugins/cuesheet/cuesheet.py @@ -3,7 +3,7 @@ PLUGIN_NAME = "Generate Cuesheet" PLUGIN_AUTHOR = "Lukáš Lalinský, Sambhav Kothari" PLUGIN_DESCRIPTION = "Generate cuesheet (.cue file) from an album." -PLUGIN_VERSION = "1.0" +PLUGIN_VERSION = "1.1" PLUGIN_API_VERSIONS = ["2.0"] @@ -134,8 +134,9 @@ def write(self): elif line[0] != "FILE": indent = 4 line2 = " ".join([self.quote(s) for s in line]) - lines.append(" " * indent + line2.encode("UTF-8") + "\n") - with open(encode_filename(self.filename), "wt") as f: + line2 = " " * indent + line2 + "\n" + lines.append(line2.encode("UTF-8")) + with open(encode_filename(self.filename), "wb") as f: f.writelines(lines) diff --git a/plugins/decode_cyrillic/decode_cyrillic.py b/plugins/decode_cyrillic/decode_cyrillic.py index be6fc85b..a485440c 100644 --- a/plugins/decode_cyrillic/decode_cyrillic.py +++ b/plugins/decode_cyrillic/decode_cyrillic.py @@ -25,12 +25,12 @@ PLUGIN_NAME = "Decode Cyrillic" PLUGIN_AUTHOR = "aeontech" PLUGIN_DESCRIPTION = ''' -This plugin helps you quickly convert mis-encoded cyrillic Windows-1251 tags +This plugin helps you quickly convert mis-encoded cyrillic Windows-1251 tags to proper UTF-8 encoded strings. If your track/album names look something like "Àëèñà â ñò›àíå ÷óäåñ", run this plugin from the context menu before running the "Lookup" or "Scan" tools ''' -PLUGIN_VERSION = "1.0" +PLUGIN_VERSION = "1.1" PLUGIN_API_VERSIONS = ["1.0", "2.0"] PLUGIN_LICENSE = "MIT" PLUGIN_LICENSE_URL = "https://opensource.org/licenses/MIT" @@ -40,10 +40,10 @@ from picard.ui.itemviews import BaseAction, register_cluster_action _decode_tags = [ - 'title', - 'albumartist', - 'artist', - 'album', + 'title', + 'albumartist', + 'artist', + 'album', 'artistsort' ] # _from_encoding = "latin1" @@ -52,7 +52,7 @@ # TODO: # - extend to support multiple codepage decoding, not just cp1251->latin1 -# instead, try the common variations, and show a dialog to the user, +# instead, try the common variations, and show a dialog to the user, # allowing him to select the correct transcoding. See 2cyr.com for example. # - also see http://stackoverflow.com/questions/23326531/how-to-decode-cp1252-string @@ -61,8 +61,9 @@ class DecodeCyrillic(BaseAction): def unmangle(self, tag, value): try: + print(value, value.encode('latin1')) unmangled_value = value.encode('latin1').decode('cp1251') - except UnicodeEncodeError: + except UnicodeError: unmangled_value = value log.debug("%s: could not unmangle tag %s; original value: %s" % (PLUGIN_NAME, tag, value)) return unmangled_value @@ -84,7 +85,7 @@ def callback(self, objs): log.debug("%s: Trying to unmangle file - original metadata %s" % (PLUGIN_NAME, file.orig_metadata)) - for tag in _decode_tags: + for tag in _decode_tags: if not (tag in file.metadata): continue diff --git a/plugins/fanarttv/__init__.py b/plugins/fanarttv/__init__.py index 96f3815f..88f515af 100644 --- a/plugins/fanarttv/__init__.py +++ b/plugins/fanarttv/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2015 Philipp Wolfer +# Copyright (C) 2015-2019 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -19,23 +19,29 @@ PLUGIN_NAME = 'fanart.tv cover art' PLUGIN_AUTHOR = 'Philipp Wolfer, Sambhav Kothari' -PLUGIN_DESCRIPTION = 'Use cover art from fanart.tv. To use this plugin you have to register a personal API key on https://fanart.tv/get-an-api-key/' -PLUGIN_VERSION = "1.2" -PLUGIN_API_VERSIONS = ["2.0"] -PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_DESCRIPTION = ('Use cover art from fanart.tv. To use this plugin you ' + 'have to register a personal API key on ' + 'https://fanart.tv/get-an-api-key/') +PLUGIN_VERSION = "1.5.2" +PLUGIN_API_VERSIONS = ["2.0", "2.1", "2.2"] +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" -import traceback from functools import partial from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkReply from picard import config, log -from picard.coverart.providers import CoverArtProvider, register_cover_art_provider +from picard.coverart.providers import ( + CoverArtProvider, + register_cover_art_provider, +) from picard.coverart.image import CoverArtImage -from picard.ui.options import register_options_page, OptionsPage -from picard.util import load_json +from picard.ui.options import ( + register_options_page, + OptionsPage, +) from picard.config import TextOption -from picard.plugins.fanarttv.ui_options_fanarttv import Ui_FanartTvOptionsPage +from .ui_options_fanarttv import Ui_FanartTvOptionsPage FANART_HOST = "webservice.fanart.tv" FANART_PORT = 80 @@ -54,9 +60,13 @@ def cover_sort_key(cover): return 0 +def encode_queryarg(arg): + return bytes(QUrl.toPercentEncoding(arg)).decode() + + class FanartTvCoverArtImage(CoverArtImage): - """Image from Cover Art Archive""" + """Image from fanart.tv""" support_types = True sourceprefix = "FATV" @@ -69,25 +79,25 @@ class CoverArtProviderFanartTv(CoverArtProvider): NAME = "fanart.tv" def enabled(self): - return self._client_key != "" and \ - super(CoverArtProviderFanartTv, self).enabled() and \ - not self.coverart.front_image_found + return (self._client_key and super().enabled() + and not self.coverart.front_image_found) def queue_images(self): release_group_id = self.metadata["musicbrainz_releasegroupid"] - path = "/v3/music/albums/%s" % \ - (release_group_id, ) - queryargs = {"api_key": QUrl.toPercentEncoding(FANART_APIKEY), - "client_key": QUrl.toPercentEncoding(self._client_key), - } + path = "/v3/music/albums/%s" % (release_group_id, ) + queryargs = { + "api_key": encode_queryarg(FANART_APIKEY), + "client_key": encode_queryarg(self._client_key), + } log.debug("CoverArtProviderFanartTv.queue_downloads: %s" % path) - self.album.tagger.webservice.download( + self.album.tagger.webservice.get( FANART_HOST, FANART_PORT, path, partial(self._json_downloaded, release_group_id), priority=True, important=False, + parse_response_type='json', queryargs=queryargs) self.album._requests += 1 return CoverArtProvider.WAIT @@ -104,28 +114,30 @@ def _json_downloaded(self, release_group_id, data, reply, error): error_level = log.error else: error_level = log.debug - error_level("Problem requesting metadata in fanart.tv plugin: %s", error) + error_level("Problem requesting metadata in fanart.tv plugin: %s", + error) else: try: - response = load_json(data) - release = response["albums"][release_group_id] + release = data["albums"][release_group_id] + has_cover = "albumcover" in release + has_cdart = "cdart" in release + use_cdart = config.setting["fanarttv_use_cdart"] - if "albumcover" in release: + if has_cover: covers = release["albumcover"] types = ["front"] self._select_and_add_cover_art(covers, types) - if "cdart" in release and \ - (config.setting["fanarttv_use_cdart"] == OPTION_CDART_ALWAYS - or (config.setting["fanarttv_use_cdart"] == OPTION_CDART_NOALBUMART - and "albumcover" not in release)): + if has_cdart and (use_cdart == OPTION_CDART_ALWAYS + or (use_cdart == OPTION_CDART_NOALBUMART + and not has_cover)): covers = release["cdart"] types = ["medium"] - if not "albumcover" in release: + if not has_cover: types.append("front") self._select_and_add_cover_art(covers, types) - except: - log.error("Problem processing downloaded metadata in fanart.tv plugin: %s", traceback.format_exc()) + except (AttributeError, KeyError, TypeError): + log.error("Problem processing downloaded metadata in fanart.tv plugin", exc_info=True) self.next_in_queue() @@ -148,27 +160,29 @@ class FanartTvOptionsPage(OptionsPage): ] def __init__(self, parent=None): - super(FanartTvOptionsPage, self).__init__(parent) + super().__init__(parent) self.ui = Ui_FanartTvOptionsPage() self.ui.setupUi(self) def load(self): - self.ui.fanarttv_client_key.setText(config.setting["fanarttv_client_key"]) - if config.setting["fanarttv_use_cdart"] == OPTION_CDART_ALWAYS: + setting = config.setting + self.ui.fanarttv_client_key.setText(setting["fanarttv_client_key"]) + if setting["fanarttv_use_cdart"] == OPTION_CDART_ALWAYS: self.ui.fanarttv_cdart_use_always.setChecked(True) - elif config.setting["fanarttv_use_cdart"] == OPTION_CDART_NEVER: + elif setting["fanarttv_use_cdart"] == OPTION_CDART_NEVER: self.ui.fanarttv_cdart_use_never.setChecked(True) - elif config.setting["fanarttv_use_cdart"] == OPTION_CDART_NOALBUMART: + elif setting["fanarttv_use_cdart"] == OPTION_CDART_NOALBUMART: self.ui.fanarttv_cdart_use_if_no_albumcover.setChecked(True) def save(self): - config.setting["fanarttv_client_key"] = string_(self.ui.fanarttv_client_key.text()) + setting = config.setting + setting["fanarttv_client_key"] = self.ui.fanarttv_client_key.text() if self.ui.fanarttv_cdart_use_always.isChecked(): - config.setting["fanarttv_use_cdart"] = OPTION_CDART_ALWAYS + setting["fanarttv_use_cdart"] = OPTION_CDART_ALWAYS elif self.ui.fanarttv_cdart_use_never.isChecked(): - config.setting["fanarttv_use_cdart"] = OPTION_CDART_NEVER + setting["fanarttv_use_cdart"] = OPTION_CDART_NEVER elif self.ui.fanarttv_cdart_use_if_no_albumcover.isChecked(): - config.setting["fanarttv_use_cdart"] = OPTION_CDART_NOALBUMART + setting["fanarttv_use_cdart"] = OPTION_CDART_NOALBUMART register_cover_art_provider(CoverArtProviderFanartTv) diff --git a/plugins/featartistsintitles/featartistsintitles.py b/plugins/featartistsintitles/featartistsintitles.py index 648675a9..4fb6dd9e 100644 --- a/plugins/featartistsintitles/featartistsintitles.py +++ b/plugins/featartistsintitles/featartistsintitles.py @@ -1,7 +1,7 @@ PLUGIN_NAME = 'Feat. Artists in Titles' -PLUGIN_AUTHOR = 'Lukas Lalinsky, Michael Wiencek, Bryan Toth' +PLUGIN_AUTHOR = 'Lukas Lalinsky, Michael Wiencek, Bryan Toth, JeromyNix (NobahdiAtoll)' PLUGIN_DESCRIPTION = 'Move "feat." from artist names to album and track titles. Match is case insensitive.' -PLUGIN_VERSION = "0.3" +PLUGIN_VERSION = "0.4" PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15", "0.16", "2.0"] from picard.metadata import register_album_metadata_processor, register_track_metadata_processor @@ -15,6 +15,9 @@ def move_album_featartists(tagger, metadata, release): if match: metadata["albumartist"] = match.group(1) metadata["album"] += " (feat.%s)" % match.group(2) + match = _feat_re.match(metadata["albumartistsort"]) + if match: + metadata["albumartistsort"] = match.group(1) def move_track_featartists(tagger, metadata, release, track): @@ -22,6 +25,9 @@ def move_track_featartists(tagger, metadata, release, track): if match: metadata["artist"] = match.group(1) metadata["title"] += " (feat.%s)" % match.group(2) + match = _feat_re.match(metadata["artistsort"]) + if match: + metadata["artistsort"] = match.group(1) register_album_metadata_processor(move_album_featartists) register_track_metadata_processor(move_track_featartists) diff --git a/plugins/fix_tracknums/fix_tracknums.py b/plugins/fix_tracknums/fix_tracknums.py index 3a9de47a..4f990910 100644 --- a/plugins/fix_tracknums/fix_tracknums.py +++ b/plugins/fix_tracknums/fix_tracknums.py @@ -54,9 +54,9 @@ ''' -PLUGIN_VERSION = '0.1' +PLUGIN_VERSION = '0.2' PLUGIN_API_VERSIONS = ['0.15', '1.0', '2.0'] -PLUGIN_LICENSE = 'GPL-3.0' +PLUGIN_LICENSE = 'GPL-3.0-or-later' PLUGIN_LICENSE_URL = 'https://www.gnu.org/licenses/gpl.txt' from picard import log diff --git a/plugins/format_performer_tags/__init__.py b/plugins/format_performer_tags/__init__.py new file mode 100644 index 00000000..2d4603bc --- /dev/null +++ b/plugins/format_performer_tags/__init__.py @@ -0,0 +1,359 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 Bob Swift (rdswift) +# +# 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. + +PLUGIN_NAME = 'Format Performer Tags' +PLUGIN_AUTHOR = 'Bob Swift (rdswift), Philipp Wolfer' +PLUGIN_DESCRIPTION = ''' +This plugin provides options with respect to the formatting of performer +tags. It has been developed using the 'Standardise Performers' plugin by +Sophist as the basis for retrieving and processing the performer data for +each of the tracks. The format of the resulting tags can be customized +in the option settings page. +''' + +PLUGIN_VERSION = "0.6" +PLUGIN_API_VERSIONS = ["2.0"] +PLUGIN_LICENSE = "GPL-2.0-or-later" +PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" + +PLUGIN_USER_GUIDE_URL = "https://github.com/rdswift/picard-plugins/blob/2.0_RDS_Plugins/plugins/format_performer_tags/docs/README.md" + +DEV_TESTING = False + +import re +from picard import config, log +from picard.metadata import Metadata, register_track_metadata_processor +from picard.plugin import PluginPriority +from picard.ui.options import register_options_page, OptionsPage +from picard.plugins.format_performer_tags.ui_options_format_performer_tags import Ui_FormatPerformerTagsOptionsPage + +performers_split = re.compile(r", | and ").split + +WORD_LIST = ['guest', 'solo', 'additional'] + + +def get_word_dict(settings): + word_dict = {} + for word in WORD_LIST: + word_dict[word] = settings['format_group_' + word] + return word_dict + + +def rewrite_tag(key, values, metadata, word_dict, settings): + mainkey, subkey = key.split(':', 1) + if not subkey: + return + log.debug("%s: Formatting Performer [%s: %s]", PLUGIN_NAME, subkey, values,) + instruments = performers_split(subkey) + for instrument in instruments: + if DEV_TESTING: + log.debug("%s: instrument (first pass): '%s'", PLUGIN_NAME, instrument,) + if instrument in WORD_LIST: + instruments[0] = "{0} {1}".format(instruments[0], instrument,) + instruments.remove(instrument) + groups = { 1: [], 2: [], 3: [], 4: [], } + words = instruments[0].split() + for word in words[:]: + if word in WORD_LIST: + groups[word_dict[word]].append(word) + words.remove(word) + instruments[0] = " ".join(words) + display_group = {} + for group_number in range(1, 5): + if groups[group_number]: + if DEV_TESTING: + log.debug("%s: groups[%s]: %s", PLUGIN_NAME, group_number, groups[group_number],) + group_separator = settings["format_group_{0}_sep_char".format(group_number)] + if not group_separator: + group_separator = " " + display_group[group_number] = settings["format_group_{0}_start_char".format(group_number)] \ + + group_separator.join(groups[group_number]) \ + + settings["format_group_{0}_end_char".format(group_number)] + else: + display_group[group_number] = "" + if DEV_TESTING: + log.debug("%s: display_group: %s", PLUGIN_NAME, display_group,) + metadata.delete(key) + for instrument in instruments: + if DEV_TESTING: + log.debug("%s: instrument (second pass): '%s'", PLUGIN_NAME, instrument,) + words = instrument.split() + if (len(words) > 1) and (words[-1] in ["vocal", "vocals",]): + vocals = " ".join(words[:-1]) + instrument = words[-1] + else: + vocals = "" + if vocals: + group_number = settings["format_group_vocals"] + temp_group = groups[group_number][:] + if group_number < 2: + temp_group.append(vocals) + else: + temp_group.insert(0, vocals) + group_separator = settings["format_group_{0}_sep_char".format(group_number)] + if not group_separator: + group_separator = " " + display_group[group_number] = settings["format_group_{0}_start_char".format(group_number)] \ + + group_separator.join(temp_group) \ + + settings["format_group_{0}_end_char".format(group_number)] + + newkey = ('%s:%s%s%s%s' % (mainkey, display_group[1], instrument, display_group[2], display_group[3],)) + log.debug("%s: newkey: %s", PLUGIN_NAME, newkey,) + for value in values: + metadata.add_unique(newkey, (value + display_group[4])) + + +def format_performer_tags(album, metadata, *args): + word_dict = get_word_dict(config.setting) + for key, values in list(filter(lambda filter_tuple: filter_tuple[0].startswith('performer:') or filter_tuple[0].startswith('~performersort:'), metadata.rawitems())): + rewrite_tag(key, values, metadata, word_dict, config.setting) + + +class FormatPerformerTagsOptionsPage(OptionsPage): + + NAME = "format_performer_tags" + TITLE = "Format Performer Tags" + PARENT = "plugins" + + options = [ + config.IntOption("setting", "format_group_additional", 3), + config.IntOption("setting", "format_group_guest", 4), + config.IntOption("setting", "format_group_solo", 3), + config.IntOption("setting", "format_group_vocals", 2), + config.TextOption("setting", "format_group_1_start_char", ''), + config.TextOption("setting", "format_group_1_end_char", ' '), + config.TextOption("setting", "format_group_1_sep_char", ''), + config.TextOption("setting", "format_group_2_start_char", ', '), + config.TextOption("setting", "format_group_2_end_char", ''), + config.TextOption("setting", "format_group_2_sep_char", ''), + config.TextOption("setting", "format_group_3_start_char", ' ('), + config.TextOption("setting", "format_group_3_end_char", ')'), + config.TextOption("setting", "format_group_3_sep_char", ''), + config.TextOption("setting", "format_group_4_start_char", ' ('), + config.TextOption("setting", "format_group_4_end_char", ')'), + config.TextOption("setting", "format_group_4_sep_char", ''), + ] + + def __init__(self, parent=None): + super(FormatPerformerTagsOptionsPage, self).__init__(parent) + self.ui = Ui_FormatPerformerTagsOptionsPage() + self.ui.setupUi(self) + self.ui.additional_rb_1.clicked.connect(self.update_examples) + self.ui.additional_rb_2.clicked.connect(self.update_examples) + self.ui.additional_rb_3.clicked.connect(self.update_examples) + self.ui.additional_rb_4.clicked.connect(self.update_examples) + self.ui.guest_rb_1.clicked.connect(self.update_examples) + self.ui.guest_rb_2.clicked.connect(self.update_examples) + self.ui.guest_rb_3.clicked.connect(self.update_examples) + self.ui.guest_rb_4.clicked.connect(self.update_examples) + self.ui.solo_rb_1.clicked.connect(self.update_examples) + self.ui.solo_rb_2.clicked.connect(self.update_examples) + self.ui.solo_rb_3.clicked.connect(self.update_examples) + self.ui.solo_rb_4.clicked.connect(self.update_examples) + self.ui.vocals_rb_1.clicked.connect(self.update_examples) + self.ui.vocals_rb_2.clicked.connect(self.update_examples) + self.ui.vocals_rb_3.clicked.connect(self.update_examples) + self.ui.vocals_rb_4.clicked.connect(self.update_examples) + self.ui.format_group_1_start_char.editingFinished.connect(self.update_examples) + self.ui.format_group_2_start_char.editingFinished.connect(self.update_examples) + self.ui.format_group_3_start_char.editingFinished.connect(self.update_examples) + self.ui.format_group_4_start_char.editingFinished.connect(self.update_examples) + self.ui.format_group_1_sep_char.editingFinished.connect(self.update_examples) + self.ui.format_group_2_sep_char.editingFinished.connect(self.update_examples) + self.ui.format_group_3_sep_char.editingFinished.connect(self.update_examples) + self.ui.format_group_4_sep_char.editingFinished.connect(self.update_examples) + self.ui.format_group_1_end_char.editingFinished.connect(self.update_examples) + self.ui.format_group_2_end_char.editingFinished.connect(self.update_examples) + self.ui.format_group_3_end_char.editingFinished.connect(self.update_examples) + self.ui.format_group_4_end_char.editingFinished.connect(self.update_examples) + + def load(self): + # Enable external link + self.ui.format_description.setOpenExternalLinks(True) + + # Settings for Keyword: additional + temp = config.setting["format_group_additional"] + if temp > 3: + self.ui.additional_rb_4.setChecked(True) + elif temp > 2: + self.ui.additional_rb_3.setChecked(True) + elif temp > 1: + self.ui.additional_rb_2.setChecked(True) + else: + self.ui.additional_rb_1.setChecked(True) + + # Settings for Keyword: guest + temp = config.setting["format_group_guest"] + if temp > 3: + self.ui.guest_rb_4.setChecked(True) + elif temp > 2: + self.ui.guest_rb_3.setChecked(True) + elif temp > 1: + self.ui.guest_rb_2.setChecked(True) + else: + self.ui.guest_rb_1.setChecked(True) + + # Settings for Keyword: solo + temp = config.setting["format_group_solo"] + if temp > 3: + self.ui.solo_rb_4.setChecked(True) + elif temp > 2: + self.ui.solo_rb_3.setChecked(True) + elif temp > 1: + self.ui.solo_rb_2.setChecked(True) + else: + self.ui.solo_rb_1.setChecked(True) + + # Settings for all vocal keywords + temp = config.setting["format_group_vocals"] + if temp > 3: + self.ui.vocals_rb_4.setChecked(True) + elif temp > 2: + self.ui.vocals_rb_3.setChecked(True) + elif temp > 1: + self.ui.vocals_rb_2.setChecked(True) + else: + self.ui.vocals_rb_1.setChecked(True) + + # Settings for word group 1 + self.ui.format_group_1_start_char.setText(config.setting["format_group_1_start_char"]) + self.ui.format_group_1_end_char.setText(config.setting["format_group_1_end_char"]) + self.ui.format_group_1_sep_char.setText(config.setting["format_group_1_sep_char"]) + + # Settings for word group 2 + self.ui.format_group_2_start_char.setText(config.setting["format_group_2_start_char"]) + self.ui.format_group_2_end_char.setText(config.setting["format_group_2_end_char"]) + self.ui.format_group_2_sep_char.setText(config.setting["format_group_2_sep_char"]) + + # Settings for word group 3 + self.ui.format_group_3_start_char.setText(config.setting["format_group_3_start_char"]) + self.ui.format_group_3_end_char.setText(config.setting["format_group_3_end_char"]) + self.ui.format_group_3_sep_char.setText(config.setting["format_group_3_sep_char"]) + + # Settings for word group 4 + self.ui.format_group_4_start_char.setText(config.setting["format_group_4_start_char"]) + self.ui.format_group_4_end_char.setText(config.setting["format_group_4_end_char"]) + self.ui.format_group_4_sep_char.setText(config.setting["format_group_4_sep_char"]) + self.update_examples() + + # TODO: Modify self.format_description in ui_options_format_performer_tags.py to include a placeholder + # such as {user_guide_url} so that the translated string can be formatted to include the value + # of PLUGIN_USER_GUIDE_URL to dynamically set the link while not requiring retranslation if the + # link changes. Preliminary code something like: + # + # temp = (self.ui.format_description.text).format(user_guide_url=PLUGIN_USER_GUIDE_URL,) + # self.ui.format_description.setText(temp) + + + def save(self): + self._set_settings(config.setting) + + def restore_defaults(self): + super().restore_defaults() + self.update_examples() + + def _set_settings(self, settings): + + # Process 'additional' keyword settings + temp = 1 + if self.ui.additional_rb_2.isChecked(): temp = 2 + if self.ui.additional_rb_3.isChecked(): temp = 3 + if self.ui.additional_rb_4.isChecked(): temp = 4 + settings["format_group_additional"] = temp + + # Process 'guest' keyword settings + temp = 1 + if self.ui.guest_rb_2.isChecked(): temp = 2 + if self.ui.guest_rb_3.isChecked(): temp = 3 + if self.ui.guest_rb_4.isChecked(): temp = 4 + settings["format_group_guest"] = temp + + # Process 'solo' keyword settings + temp = 1 + if self.ui.solo_rb_2.isChecked(): temp = 2 + if self.ui.solo_rb_3.isChecked(): temp = 3 + if self.ui.solo_rb_4.isChecked(): temp = 4 + settings["format_group_solo"] = temp + + # Process all vocal keyword settings + temp = 1 + if self.ui.vocals_rb_2.isChecked(): temp = 2 + if self.ui.vocals_rb_3.isChecked(): temp = 3 + if self.ui.vocals_rb_4.isChecked(): temp = 4 + settings["format_group_vocals"] = temp + + # Settings for word group 1 + settings["format_group_1_start_char"] = self.ui.format_group_1_start_char.text() + settings["format_group_1_end_char"] = self.ui.format_group_1_end_char.text() + settings["format_group_1_sep_char"] = self.ui.format_group_1_sep_char.text() + + # Settings for word group 2 + settings["format_group_2_start_char"] = self.ui.format_group_2_start_char.text() + settings["format_group_2_end_char"] = self.ui.format_group_2_end_char.text() + settings["format_group_2_sep_char"] = self.ui.format_group_2_sep_char.text() + + # Settings for word group 3 + settings["format_group_3_start_char"] = self.ui.format_group_3_start_char.text() + settings["format_group_3_end_char"] = self.ui.format_group_3_end_char.text() + settings["format_group_3_sep_char"] = self.ui.format_group_3_sep_char.text() + + # Settings for word group 4 + settings["format_group_4_start_char"] = self.ui.format_group_4_start_char.text() + settings["format_group_4_end_char"] = self.ui.format_group_4_end_char.text() + settings["format_group_4_sep_char"] = self.ui.format_group_4_sep_char.text() + + def update_examples(self): + settings = {} + self._set_settings(settings) + word_dict = get_word_dict(settings) + + instruments_credits = { + "guitar": ["Johnny Flux", "John Watson"], + "guest guitar": ["Jimmy Page"], + "additional guest solo guitar": ["Jimmy Page"], + } + instruments_example = self.build_example(instruments_credits, word_dict, settings) + self.ui.example_instruments.setText(instruments_example) + + vocals_credits = { + "additional solo lead vocals": ["Robert Plant"], + "additional solo guest lead vocals": ["Sandy Denny"], + } + vocals_example = self.build_example(vocals_credits, word_dict, settings) + self.ui.example_vocals.setText(vocals_example) + + @staticmethod + def build_example(credits, word_dict, settings): + prefix = "performer:" + metadata = Metadata() + for key, values in credits.items(): + rewrite_tag(prefix + key, values, metadata, word_dict, settings) + + examples = [] + for key, values in metadata.rawitems(): + credit = "%s: %s" % (key, ", ".join(values)) + if credit.startswith(prefix): + credit = credit[len(prefix):] + examples.append(credit) + return "\n".join(examples) + + +# Register the plugin to run at a HIGH priority. +register_track_metadata_processor(format_performer_tags, priority=PluginPriority.HIGH) +register_options_page(FormatPerformerTagsOptionsPage) diff --git a/plugins/format_performer_tags/docs/HISTORY.md b/plugins/format_performer_tags/docs/HISTORY.md new file mode 100644 index 00000000..60ea8cbb --- /dev/null +++ b/plugins/format_performer_tags/docs/HISTORY.md @@ -0,0 +1,45 @@ +# Format Performer Tags + +## Contributors + +The following people have contributed to the development of this plugin. + +* Bob Swift ([rdswift](https://github.com/rdswift/)) +* Philipp Wolfer ([phw](https://github.com/phw/)) + +--- + +## Revision History + +The following identifies the development history of the plugin, in reverse chronological order. Each version lists the changes made for that version, along with the author of each change. + +### Version 0.6 + +* Update the user interface. Add live examples when settings are changed. \[phw\] +* Update plugin metadata. \[phw\] +* Add `HISTORY.md` file containing the contributors list and revision history. \[rdswift\] + +### Version 0.5 + +* Reformat long lines. \[rdswift\] +* Add TODO note about language translation. \[rdswift\] + +### Version 0.4 + +* Remove code to strip extra whitespace from key and value strings. \[rdswift\] + +### Version 0.3 + +* Update to use four user-defined sections. \[rdswift\] +* Add user guide. \[rdswift\] + +### Version 0.2 + +* Fix bug that caused some performer records to be missed in the processing. \[rdswift\] +* Add vocals performer processing. \[rdswift\] + +### Version 0.1 + +* Initial testing release. \[rdswift\] + +--- diff --git a/plugins/format_performer_tags/docs/README.md b/plugins/format_performer_tags/docs/README.md new file mode 100644 index 00000000..cafc5f7b --- /dev/null +++ b/plugins/format_performer_tags/docs/README.md @@ -0,0 +1,282 @@ +# Format Performer Tags \[[Download](https://github.com/rdswift/picard-plugins/raw/2.0_RDS_Plugins/plugins/format_performer_tags/format_performer_tags.zip)\] + +## Overview + +This plugin allows the user to configure the way that instrument and vocal performer tags are written. Once +installed a settings page will be added to Picard's options, which is where the plugin is configured. + +--- + +## What it Does + +This plugin serves two purposes. + +### First: + +Picard will by default try to order the performer/instrument credits by the name of the performers, summing up all instruments for that performer in one line. +This plugin will order the performer/instrument credits by instrument, summing up all performers that play them. + +So instead of this: + +``` +background vocals and drums: Wayne Gretzky +bass and background vocals: Leslie Nielsen +guitar, keyboard and synthesizer: Edward Hopper +guitar and vocals: Vladimir Nabokov +keyboard and lead vocals: Bianca Castafiore +``` + +It will be displayed like this: + +``` +background vocals: Wayne Gretzky, Leslie Nielsen +bass: Leslie Nielsen +drums: Wayne Gretzky +guitar: Edward Hopper, Vladimir Nabokov +keyboard: Edward Hopper, Bianca Castafiore +lead vocals: Bianca Castafiore +synthesizer: Edward Hopper +vocals: Vladimir Nabokov +``` + +### Second: + +This plugin allows fine-tuned organization of how instruments, performers and additional descriptions (keywords) are displayed in the instrument/performer tags. + +Here is some background information about these keywords: + +MusicBrainz' database allows to add and store keywords (attributes) that will refine or additionally describe the role of a performer for a recording. +For an artist performing music on an instrument, these are the three attributes (keywords) that MusicBrainz can store, and offer Picard: + +* additional +* guest +* solo + +For an artist performing with his/her voice, MusicBrainz has this restricted list of keywords describing the role or the register of the voice: + +* background vocals +* choir vocals +* lead vocals + * alto vocals + * baritone vocals + * bass vocals + * bass-baritone vocals + * contralto vocals + * countertenor vocals + * mezzo-soprano vocals + * soprano vocals + * tenor vocals + * treble vocals +* other vocals + * spoken vocals + + +Picard can retrieve and display these keywords and will list them all together in front of the performer. +The result will be something like this: + +``` +guitar and solo guitar: Bob 'Swift' Fingers +additional drums: Rob Reiner (guest) +additional baritone vocals: Hermann Rorschach +guest soprano vocals: Bianca Castafiore +``` + +The problem with this is that it is a bit indistinct if these keywords say something about the instrument, the artist and their performing role, the voice's register, or the persons relation to the group/orchestra. +For instance: + +* 'additional' is referring to instrumentation. For example 'additional percussion'. +* 'guest' is referring to the performer as a person. Indicating that they are a guest in that band/orchestra instead of a regular member. +* 'solo' is referring to a specific role a musician performs in a composition. For example a musician performing a guitar solo. +* 'soprano vocals' is saying something about the register of a performer's voice. + +So you might want to attach 'solo' to the instrument, 'baritone' to the vocals, and 'guest' to the performer. +This plugin allows you to do that, so you could have something like this as a result: + +``` +guitar [solo]: Bob 'Swift' Fingers +drums ‹additional› : Rob Reiner (guest) +vocals, baritone ‹additional› : Hermann Rorschach +vocals, soprano: Bianca Castafiore (guest) +``` + +--- + +## How it Works + +This is the concept behind the workings of this plugin: + +The basic structure of a performer tag such as Picard produces it is: + + [Keywords] Instrument / Vocals: Performer + +This plugin makes four different 'Sections' at fixed positions in these tags available. +Their positions are: + + [Section 1]Instrument / Vocals[Section 2][Section 3]: Performer[Section 4] + +In the settings panel you can define in what section (at what location) you want each of the available keywords to be displayed. +You can do that by simply selecting the section number for that (group of) keyword(s). + +You can also define what characters you want to use for delimiting or surrounding the keywords. +For some situations you might want to use the more common parenthesis brackets ( ), or maybe you prefer less common brackets such as \[ \] or ‹ ›. +Note that using default parenthesis might confuse possible subsequent tagging formulas in the music player/manager of your choice. +You can also just leave them blank, or use commas, spaces, etc. + +Note that the plugin does not add any spaces as separators by default, so you will need to define those to your personal liking. + +--- + +## Settings + +The first group of settings is the **Keyword Section Assignments**. This is where you select the section in +which each of the keywords will be displayed. Selection is made by clicking the radio button corresponding +to the desired section for each of the keywords. + +The second group of settings is the **Section Display Settings**. This is where you configure the text +included at the beginning and end of each section displayed, and the characters used to separate multiple +items within a section. Note that leading or trailing spaces must be included in the settings and will not +be automatically added. If no separator characters are entered, the items will be automatically separated +by a single space. + +The initial default settings are: + + +![default settings image](default_settings.jpg) + +These settings will produce tags such as: + +``` +rhodes piano (solo): Billy Preston (guest) +percussion: Steve Berlin (guest), Kris MacFarlane, Séan McCann +vocals, background: Jeen (guest) +``` + +--- + +## Examples + +The following are some examples using actual information from MusicBrainz: + +### Example 1: + +(add example) + +### Example 2: + +(add example) + +### Example 3: + +(add example) + +--- + +## Credits + +Special thank-you to [hiccup](https://musicbrainz.org/user/hiccup) for improvement suggestions and extensive testing during +the development of this plugin, and for providing the write-up and examples that formed the basis for this User Guide. + + + + diff --git a/plugins/format_performer_tags/docs/default_settings.jpg b/plugins/format_performer_tags/docs/default_settings.jpg new file mode 100644 index 00000000..0466e10e Binary files /dev/null and b/plugins/format_performer_tags/docs/default_settings.jpg differ diff --git a/plugins/format_performer_tags/options_format_performer_tags.ui b/plugins/format_performer_tags/options_format_performer_tags.ui new file mode 100644 index 00000000..6b24524e --- /dev/null +++ b/plugins/format_performer_tags/options_format_performer_tags.ui @@ -0,0 +1,722 @@ + + + FormatPerformerTagsOptionsPage + + + + 0 + 0 + 561 + 802 + + + + + 360 + 0 + + + + Form + + + + + + QFrame::NoFrame + + + true + + + + + 0 + -13 + 529 + 848 + + + + + + + Format Performer Tags + + + + + + + 0 + 0 + + + + <html><head/><body><p>These settings will determine the format for any <span style=" font-weight:600;">Performer</span> tags prepared. The format is divided into six parts: the performer; the instrument or &quot;vocals&quot;; and four user selectable sections for the extra information. This is set out as:</p><p align="center"><span style=" font-weight:600;">[Section 1]</span>Instrument/Vocals<span style=" font-weight:600;">[Section 2][Section 3]</span>: Performer<span style=" font-weight:600;">[Section 4]</span></p><p>You can select the section in which each of the extra information words appears.</p><p>For each of the sections you can select the starting character(s), the character(s) separating entries, and the ending character(s). Note that leading or trailing spaces must be included in the settings and will not be automatically added. If no separator characters are entered, the items within a section will be automatically separated by a single space.</p><p>Please visit the repository on GitHub for <a href="https://github.com/rdswift/picard-plugins/blob/2.0_RDS_Plugins/plugins/format_performer_tags/docs/README.md"><span style=" text-decoration: underline; color:#0000ff;">additional information</span></a>.</p></body></html> + + + true + + + + + + + + + + Keyword Sections Assignment + + + + + + Keyword: additional + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + 40 + 16777215 + + + + 1 + + + + + + + + 40 + 16777215 + + + + 2 + + + + + + + + 40 + 16777215 + + + + 3 + + + + + + + + 40 + 16777215 + + + + 4 + + + + + + + + + + Keyword: guest + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + 40 + 16777215 + + + + 1 + + + + + + + + 40 + 16777215 + + + + 2 + + + + + + + + 40 + 16777215 + + + + 3 + + + + + + + + 40 + 16777215 + + + + 4 + + + + + + + + + + Keyword: solo + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + 40 + 16777215 + + + + 1 + + + + + + + + 40 + 16777215 + + + + 2 + + + + + + + + 40 + 16777215 + + + + 3 + + + + + + + + 40 + 16777215 + + + + 4 + + + + + + + + + + All vocal type keywords + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + 40 + 20 + + + + + 40 + 16777215 + + + + 1 + + + + + + + + 40 + 16777215 + + + + 2 + + + + + + + + 40 + 16777215 + + + + 3 + + + + + + + + 40 + 16777215 + + + + 4 + + + + + + + + + + + + + Section Display Settings + + + + + + 1 + + + + + + 0 + 0 + + + + + 75 + true + + + + Section 2 + + + + + + + + 0 + 0 + + + + + 75 + true + + + + Section 3 + + + + + + + + 50 + 16777215 + + + + (blank) + + + + + + + + 0 + 0 + + + + + 75 + true + + + + Section 1 + + + + + + + + 0 + 0 + + + + + 75 + true + + + + Section 4 + + + + + + + + 50 + 16777215 + + + + (blank) + + + + + + + + 50 + 16777215 + + + + + + + (blank) + + + + + + + + 75 + true + + + + Start Char(s) + + + + + + + + 75 + true + + + + Sep Char(s) + + + + + + + + 75 + true + + + + End Char(s) + + + + + + + + 50 + 16777215 + + + + , + + + (blank) + + + + + + + + 50 + 16777215 + + + + ( + + + (blank) + + + + + + + + 50 + 16777215 + + + + ( + + + (blank) + + + + + + + + 50 + 16777215 + + + + (blank) + + + + + + + + 50 + 16777215 + + + + (blank) + + + + + + + + 50 + 16777215 + + + + (blank) + + + + + + + + 50 + 16777215 + + + + (blank) + + + + + + + + 50 + 16777215 + + + + ) + + + (blank) + + + + + + + + 50 + 16777215 + + + + ) + + + (blank) + + + + + + + + + + + + Examples + + + + + + + + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + diff --git a/plugins/format_performer_tags/ui_options_format_performer_tags.py b/plugins/format_performer_tags/ui_options_format_performer_tags.py new file mode 100644 index 00000000..c8a9dd69 --- /dev/null +++ b/plugins/format_performer_tags/ui_options_format_performer_tags.py @@ -0,0 +1,335 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'options_format_performer_tags_2.ui' +# +# Created by: PyQt5 UI code generator 5.11.3 +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore, QtGui, QtWidgets + +class Ui_FormatPerformerTagsOptionsPage(object): + def setupUi(self, FormatPerformerTagsOptionsPage): + FormatPerformerTagsOptionsPage.setObjectName("FormatPerformerTagsOptionsPage") + FormatPerformerTagsOptionsPage.resize(561, 802) + FormatPerformerTagsOptionsPage.setMinimumSize(QtCore.QSize(360, 0)) + self.verticalLayout = QtWidgets.QVBoxLayout(FormatPerformerTagsOptionsPage) + self.verticalLayout.setObjectName("verticalLayout") + self.scrollArea = QtWidgets.QScrollArea(FormatPerformerTagsOptionsPage) + self.scrollArea.setFrameShape(QtWidgets.QFrame.NoFrame) + self.scrollArea.setWidgetResizable(True) + self.scrollArea.setObjectName("scrollArea") + self.scrollAreaWidgetContents = QtWidgets.QWidget() + self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, -13, 529, 848)) + self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.scrollAreaWidgetContents) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.gb_description = QtWidgets.QGroupBox(self.scrollAreaWidgetContents) + self.gb_description.setObjectName("gb_description") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.gb_description) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.format_description = QtWidgets.QLabel(self.gb_description) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.format_description.sizePolicy().hasHeightForWidth()) + self.format_description.setSizePolicy(sizePolicy) + self.format_description.setWordWrap(True) + self.format_description.setObjectName("format_description") + self.verticalLayout_3.addWidget(self.format_description) + self.verticalLayout_2.addWidget(self.gb_description) + self.gb_word_groups = QtWidgets.QGroupBox(self.scrollAreaWidgetContents) + self.gb_word_groups.setObjectName("gb_word_groups") + self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.gb_word_groups) + self.verticalLayout_5.setObjectName("verticalLayout_5") + self.group_additonal = QtWidgets.QGroupBox(self.gb_word_groups) + self.group_additonal.setObjectName("group_additonal") + self.horizontalLayout = QtWidgets.QHBoxLayout(self.group_additonal) + self.horizontalLayout.setContentsMargins(1, 1, 1, 1) + self.horizontalLayout.setObjectName("horizontalLayout") + self.additional_rb_1 = QtWidgets.QRadioButton(self.group_additonal) + self.additional_rb_1.setMaximumSize(QtCore.QSize(40, 16777215)) + self.additional_rb_1.setObjectName("additional_rb_1") + self.horizontalLayout.addWidget(self.additional_rb_1) + self.additional_rb_2 = QtWidgets.QRadioButton(self.group_additonal) + self.additional_rb_2.setMaximumSize(QtCore.QSize(40, 16777215)) + self.additional_rb_2.setObjectName("additional_rb_2") + self.horizontalLayout.addWidget(self.additional_rb_2) + self.additional_rb_3 = QtWidgets.QRadioButton(self.group_additonal) + self.additional_rb_3.setMaximumSize(QtCore.QSize(40, 16777215)) + self.additional_rb_3.setObjectName("additional_rb_3") + self.horizontalLayout.addWidget(self.additional_rb_3) + self.additional_rb_4 = QtWidgets.QRadioButton(self.group_additonal) + self.additional_rb_4.setMaximumSize(QtCore.QSize(40, 16777215)) + self.additional_rb_4.setObjectName("additional_rb_4") + self.horizontalLayout.addWidget(self.additional_rb_4) + self.verticalLayout_5.addWidget(self.group_additonal) + self.group_guest = QtWidgets.QGroupBox(self.gb_word_groups) + self.group_guest.setObjectName("group_guest") + self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.group_guest) + self.horizontalLayout_2.setContentsMargins(1, 1, 1, 1) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.guest_rb_1 = QtWidgets.QRadioButton(self.group_guest) + self.guest_rb_1.setMaximumSize(QtCore.QSize(40, 16777215)) + self.guest_rb_1.setObjectName("guest_rb_1") + self.horizontalLayout_2.addWidget(self.guest_rb_1) + self.guest_rb_2 = QtWidgets.QRadioButton(self.group_guest) + self.guest_rb_2.setMaximumSize(QtCore.QSize(40, 16777215)) + self.guest_rb_2.setObjectName("guest_rb_2") + self.horizontalLayout_2.addWidget(self.guest_rb_2) + self.guest_rb_3 = QtWidgets.QRadioButton(self.group_guest) + self.guest_rb_3.setMaximumSize(QtCore.QSize(40, 16777215)) + self.guest_rb_3.setObjectName("guest_rb_3") + self.horizontalLayout_2.addWidget(self.guest_rb_3) + self.guest_rb_4 = QtWidgets.QRadioButton(self.group_guest) + self.guest_rb_4.setMaximumSize(QtCore.QSize(40, 16777215)) + self.guest_rb_4.setObjectName("guest_rb_4") + self.horizontalLayout_2.addWidget(self.guest_rb_4) + self.verticalLayout_5.addWidget(self.group_guest) + self.group_solo = QtWidgets.QGroupBox(self.gb_word_groups) + self.group_solo.setObjectName("group_solo") + self.horizontalLayout_3 = QtWidgets.QHBoxLayout(self.group_solo) + self.horizontalLayout_3.setContentsMargins(1, 1, 1, 1) + self.horizontalLayout_3.setObjectName("horizontalLayout_3") + self.solo_rb_1 = QtWidgets.QRadioButton(self.group_solo) + self.solo_rb_1.setMaximumSize(QtCore.QSize(40, 16777215)) + self.solo_rb_1.setObjectName("solo_rb_1") + self.horizontalLayout_3.addWidget(self.solo_rb_1) + self.solo_rb_2 = QtWidgets.QRadioButton(self.group_solo) + self.solo_rb_2.setMaximumSize(QtCore.QSize(40, 16777215)) + self.solo_rb_2.setObjectName("solo_rb_2") + self.horizontalLayout_3.addWidget(self.solo_rb_2) + self.solo_rb_3 = QtWidgets.QRadioButton(self.group_solo) + self.solo_rb_3.setMaximumSize(QtCore.QSize(40, 16777215)) + self.solo_rb_3.setObjectName("solo_rb_3") + self.horizontalLayout_3.addWidget(self.solo_rb_3) + self.solo_rb_4 = QtWidgets.QRadioButton(self.group_solo) + self.solo_rb_4.setMaximumSize(QtCore.QSize(40, 16777215)) + self.solo_rb_4.setObjectName("solo_rb_4") + self.horizontalLayout_3.addWidget(self.solo_rb_4) + self.verticalLayout_5.addWidget(self.group_solo) + self.group_vocals = QtWidgets.QGroupBox(self.gb_word_groups) + self.group_vocals.setObjectName("group_vocals") + self.horizontalLayout_4 = QtWidgets.QHBoxLayout(self.group_vocals) + self.horizontalLayout_4.setContentsMargins(1, 1, 1, 1) + self.horizontalLayout_4.setObjectName("horizontalLayout_4") + self.vocals_rb_1 = QtWidgets.QRadioButton(self.group_vocals) + self.vocals_rb_1.setMinimumSize(QtCore.QSize(40, 20)) + self.vocals_rb_1.setMaximumSize(QtCore.QSize(40, 16777215)) + self.vocals_rb_1.setObjectName("vocals_rb_1") + self.horizontalLayout_4.addWidget(self.vocals_rb_1) + self.vocals_rb_2 = QtWidgets.QRadioButton(self.group_vocals) + self.vocals_rb_2.setMaximumSize(QtCore.QSize(40, 16777215)) + self.vocals_rb_2.setObjectName("vocals_rb_2") + self.horizontalLayout_4.addWidget(self.vocals_rb_2) + self.vocals_rb_3 = QtWidgets.QRadioButton(self.group_vocals) + self.vocals_rb_3.setMaximumSize(QtCore.QSize(40, 16777215)) + self.vocals_rb_3.setObjectName("vocals_rb_3") + self.horizontalLayout_4.addWidget(self.vocals_rb_3) + self.vocals_rb_4 = QtWidgets.QRadioButton(self.group_vocals) + self.vocals_rb_4.setMaximumSize(QtCore.QSize(40, 16777215)) + self.vocals_rb_4.setObjectName("vocals_rb_4") + self.horizontalLayout_4.addWidget(self.vocals_rb_4) + self.verticalLayout_5.addWidget(self.group_vocals) + self.verticalLayout_2.addWidget(self.gb_word_groups) + self.gb_group_settings = QtWidgets.QGroupBox(self.scrollAreaWidgetContents) + self.gb_group_settings.setObjectName("gb_group_settings") + self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.gb_group_settings) + self.verticalLayout_6.setObjectName("verticalLayout_6") + self.gridLayout = QtWidgets.QGridLayout() + self.gridLayout.setVerticalSpacing(1) + self.gridLayout.setObjectName("gridLayout") + self.label_2 = QtWidgets.QLabel(self.gb_group_settings) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.label_2.sizePolicy().hasHeightForWidth()) + self.label_2.setSizePolicy(sizePolicy) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label_2.setFont(font) + self.label_2.setObjectName("label_2") + self.gridLayout.addWidget(self.label_2, 2, 0, 1, 1, QtCore.Qt.AlignLeft) + self.label_3 = QtWidgets.QLabel(self.gb_group_settings) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.label_3.sizePolicy().hasHeightForWidth()) + self.label_3.setSizePolicy(sizePolicy) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label_3.setFont(font) + self.label_3.setObjectName("label_3") + self.gridLayout.addWidget(self.label_3, 3, 0, 1, 1, QtCore.Qt.AlignLeft) + self.format_group_1_start_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_1_start_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_1_start_char.setObjectName("format_group_1_start_char") + self.gridLayout.addWidget(self.format_group_1_start_char, 1, 1, 1, 1, QtCore.Qt.AlignHCenter) + self.label = QtWidgets.QLabel(self.gb_group_settings) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.label.sizePolicy().hasHeightForWidth()) + self.label.setSizePolicy(sizePolicy) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label.setFont(font) + self.label.setObjectName("label") + self.gridLayout.addWidget(self.label, 1, 0, 1, 1, QtCore.Qt.AlignLeft) + self.label_4 = QtWidgets.QLabel(self.gb_group_settings) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.label_4.sizePolicy().hasHeightForWidth()) + self.label_4.setSizePolicy(sizePolicy) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label_4.setFont(font) + self.label_4.setObjectName("label_4") + self.gridLayout.addWidget(self.label_4, 4, 0, 1, 1, QtCore.Qt.AlignLeft) + self.format_group_1_sep_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_1_sep_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_1_sep_char.setObjectName("format_group_1_sep_char") + self.gridLayout.addWidget(self.format_group_1_sep_char, 1, 2, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_1_end_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_1_end_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_1_end_char.setObjectName("format_group_1_end_char") + self.gridLayout.addWidget(self.format_group_1_end_char, 1, 3, 1, 1, QtCore.Qt.AlignHCenter) + self.label_5 = QtWidgets.QLabel(self.gb_group_settings) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label_5.setFont(font) + self.label_5.setObjectName("label_5") + self.gridLayout.addWidget(self.label_5, 0, 1, 1, 1, QtCore.Qt.AlignHCenter) + self.label_6 = QtWidgets.QLabel(self.gb_group_settings) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label_6.setFont(font) + self.label_6.setObjectName("label_6") + self.gridLayout.addWidget(self.label_6, 0, 2, 1, 1, QtCore.Qt.AlignHCenter) + self.label_7 = QtWidgets.QLabel(self.gb_group_settings) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label_7.setFont(font) + self.label_7.setObjectName("label_7") + self.gridLayout.addWidget(self.label_7, 0, 3, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_2_start_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_2_start_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_2_start_char.setObjectName("format_group_2_start_char") + self.gridLayout.addWidget(self.format_group_2_start_char, 2, 1, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_3_start_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_3_start_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_3_start_char.setObjectName("format_group_3_start_char") + self.gridLayout.addWidget(self.format_group_3_start_char, 3, 1, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_4_start_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_4_start_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_4_start_char.setObjectName("format_group_4_start_char") + self.gridLayout.addWidget(self.format_group_4_start_char, 4, 1, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_2_sep_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_2_sep_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_2_sep_char.setObjectName("format_group_2_sep_char") + self.gridLayout.addWidget(self.format_group_2_sep_char, 2, 2, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_3_sep_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_3_sep_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_3_sep_char.setObjectName("format_group_3_sep_char") + self.gridLayout.addWidget(self.format_group_3_sep_char, 3, 2, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_4_sep_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_4_sep_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_4_sep_char.setObjectName("format_group_4_sep_char") + self.gridLayout.addWidget(self.format_group_4_sep_char, 4, 2, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_2_end_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_2_end_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_2_end_char.setObjectName("format_group_2_end_char") + self.gridLayout.addWidget(self.format_group_2_end_char, 2, 3, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_3_end_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_3_end_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_3_end_char.setObjectName("format_group_3_end_char") + self.gridLayout.addWidget(self.format_group_3_end_char, 3, 3, 1, 1, QtCore.Qt.AlignHCenter) + self.format_group_4_end_char = QtWidgets.QLineEdit(self.gb_group_settings) + self.format_group_4_end_char.setMaximumSize(QtCore.QSize(50, 16777215)) + self.format_group_4_end_char.setObjectName("format_group_4_end_char") + self.gridLayout.addWidget(self.format_group_4_end_char, 4, 3, 1, 1, QtCore.Qt.AlignHCenter) + self.verticalLayout_6.addLayout(self.gridLayout) + self.verticalLayout_2.addWidget(self.gb_group_settings) + self.gb_examples = QtWidgets.QGroupBox(self.scrollAreaWidgetContents) + self.gb_examples.setObjectName("gb_examples") + self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.gb_examples) + self.verticalLayout_4.setObjectName("verticalLayout_4") + self.example_instruments = QtWidgets.QLabel(self.gb_examples) + self.example_instruments.setText("") + self.example_instruments.setObjectName("example_instruments") + self.verticalLayout_4.addWidget(self.example_instruments) + self.example_vocals = QtWidgets.QLabel(self.gb_examples) + self.example_vocals.setText("") + self.example_vocals.setObjectName("example_vocals") + self.verticalLayout_4.addWidget(self.example_vocals) + self.verticalLayout_2.addWidget(self.gb_examples) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_2.addItem(spacerItem) + self.scrollArea.setWidget(self.scrollAreaWidgetContents) + self.verticalLayout.addWidget(self.scrollArea) + + self.retranslateUi(FormatPerformerTagsOptionsPage) + QtCore.QMetaObject.connectSlotsByName(FormatPerformerTagsOptionsPage) + + def retranslateUi(self, FormatPerformerTagsOptionsPage): + _translate = QtCore.QCoreApplication.translate + FormatPerformerTagsOptionsPage.setWindowTitle(_translate("FormatPerformerTagsOptionsPage", "Form")) + self.gb_description.setTitle(_translate("FormatPerformerTagsOptionsPage", "Format Performer Tags")) + self.format_description.setText(_translate("FormatPerformerTagsOptionsPage", "

These settings will determine the format for any Performer tags prepared. The format is divided into six parts: the performer; the instrument or "vocals"; and four user selectable sections for the extra information. This is set out as:

[Section 1]Instrument/Vocals[Section 2][Section 3]: Performer[Section 4]

You can select the section in which each of the extra information words appears.

For each of the sections you can select the starting character(s), the character(s) separating entries, and the ending character(s). Note that leading or trailing spaces must be included in the settings and will not be automatically added. If no separator characters are entered, the items within a section will be automatically separated by a single space.

Please visit the repository on GitHub for additional information.

")) + self.gb_word_groups.setTitle(_translate("FormatPerformerTagsOptionsPage", "Keyword Sections Assignment")) + self.group_additonal.setTitle(_translate("FormatPerformerTagsOptionsPage", "Keyword: additional")) + self.additional_rb_1.setText(_translate("FormatPerformerTagsOptionsPage", "1")) + self.additional_rb_2.setText(_translate("FormatPerformerTagsOptionsPage", "2")) + self.additional_rb_3.setText(_translate("FormatPerformerTagsOptionsPage", "3")) + self.additional_rb_4.setText(_translate("FormatPerformerTagsOptionsPage", "4")) + self.group_guest.setTitle(_translate("FormatPerformerTagsOptionsPage", "Keyword: guest")) + self.guest_rb_1.setText(_translate("FormatPerformerTagsOptionsPage", "1")) + self.guest_rb_2.setText(_translate("FormatPerformerTagsOptionsPage", "2")) + self.guest_rb_3.setText(_translate("FormatPerformerTagsOptionsPage", "3")) + self.guest_rb_4.setText(_translate("FormatPerformerTagsOptionsPage", "4")) + self.group_solo.setTitle(_translate("FormatPerformerTagsOptionsPage", "Keyword: solo")) + self.solo_rb_1.setText(_translate("FormatPerformerTagsOptionsPage", "1")) + self.solo_rb_2.setText(_translate("FormatPerformerTagsOptionsPage", "2")) + self.solo_rb_3.setText(_translate("FormatPerformerTagsOptionsPage", "3")) + self.solo_rb_4.setText(_translate("FormatPerformerTagsOptionsPage", "4")) + self.group_vocals.setTitle(_translate("FormatPerformerTagsOptionsPage", "All vocal type keywords")) + self.vocals_rb_1.setText(_translate("FormatPerformerTagsOptionsPage", "1")) + self.vocals_rb_2.setText(_translate("FormatPerformerTagsOptionsPage", "2")) + self.vocals_rb_3.setText(_translate("FormatPerformerTagsOptionsPage", "3")) + self.vocals_rb_4.setText(_translate("FormatPerformerTagsOptionsPage", "4")) + self.gb_group_settings.setTitle(_translate("FormatPerformerTagsOptionsPage", "Section Display Settings")) + self.label_2.setText(_translate("FormatPerformerTagsOptionsPage", "Section 2")) + self.label_3.setText(_translate("FormatPerformerTagsOptionsPage", "Section 3")) + self.format_group_1_start_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.label.setText(_translate("FormatPerformerTagsOptionsPage", "Section 1")) + self.label_4.setText(_translate("FormatPerformerTagsOptionsPage", "Section 4")) + self.format_group_1_sep_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_1_end_char.setText(_translate("FormatPerformerTagsOptionsPage", " ")) + self.format_group_1_end_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.label_5.setText(_translate("FormatPerformerTagsOptionsPage", "Start Char(s)")) + self.label_6.setText(_translate("FormatPerformerTagsOptionsPage", "Sep Char(s)")) + self.label_7.setText(_translate("FormatPerformerTagsOptionsPage", "End Char(s)")) + self.format_group_2_start_char.setText(_translate("FormatPerformerTagsOptionsPage", ", ")) + self.format_group_2_start_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_3_start_char.setText(_translate("FormatPerformerTagsOptionsPage", " (")) + self.format_group_3_start_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_4_start_char.setText(_translate("FormatPerformerTagsOptionsPage", " (")) + self.format_group_4_start_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_2_sep_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_3_sep_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_4_sep_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_2_end_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_3_end_char.setText(_translate("FormatPerformerTagsOptionsPage", ")")) + self.format_group_3_end_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.format_group_4_end_char.setText(_translate("FormatPerformerTagsOptionsPage", ")")) + self.format_group_4_end_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) + self.gb_examples.setTitle(_translate("FormatPerformerTagsOptionsPage", "Examples")) + diff --git a/plugins/instruments/instruments.py b/plugins/instruments/instruments.py new file mode 100644 index 00000000..98171cee --- /dev/null +++ b/plugins/instruments/instruments.py @@ -0,0 +1,89 @@ +# MusicBrainz Picard plugin to add an ~instruments tag. +# Copyright (C) 2019 David Mandelberg +# +# 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 3 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, see . + +PLUGIN_NAME = 'Instruments' +PLUGIN_AUTHOR = 'David Mandelberg' +PLUGIN_DESCRIPTION = """\ + Adds a multi-valued tag of all the instruments (including vocals), for use in + scripts. + """ +PLUGIN_VERSION = '1.0' +PLUGIN_API_VERSIONS = ['2.0'] +PLUGIN_LICENSE = 'GPL-3.0-or-later' +PLUGIN_LICENSE_URL = 'https://www.gnu.org/licenses/gpl-3.0.html' + +from typing import Generator, Optional + +from picard import metadata +from picard import plugin + + +def _iterate_instruments(instrument_list: str) -> Generator[str, None, None]: + """Yields individual instruments from a string listing them. + + Args: + instrument_list: List of instruments in the form 'A, B and C'. + """ + remaining = instrument_list + while remaining: + instrument, _, remaining = remaining.partition(', ') + if not remaining: + instrument, _, remaining = instrument.partition(' and ') + if ' and ' in remaining: + raise ValueError('Instrument list contains multiple \'and\'s: {!r}' + .format(instrument_list)) + yield instrument + + +def _strip_instrument_prefixes(instrument: str) -> Optional[str]: + """Returns the instrument name without qualifying prefixes, or None. + + Args: + instrument: Potentially prefixed instrument name, e.g., 'solo bassoon'. + + Returns: + The instrument name with all prefixes stripped, or None if there's nothing + other than prefixes. The all-prefixes case can happen with relationships + like 'guest performer'. + """ + instrument_prefixes = { + 'additional', + 'guest', + 'solo', + } + remaining = instrument + while remaining: + prefix, sep, remaining = remaining.partition(' ') + if prefix not in instrument_prefixes: + return ''.join((prefix, sep, remaining)) + return None + + +def add_instruments(tagger, metadata_, *args): + key_prefix = 'performer:' + instruments = set() + for key in metadata_.keys(): + if not key.startswith(key_prefix): + continue + for instrument in _iterate_instruments(key[len(key_prefix):]): + instrument = _strip_instrument_prefixes(instrument) + if instrument: + instruments.add(instrument) + metadata_['~instruments'] = list(instruments) + + +metadata.register_track_metadata_processor( + add_instruments, priority=plugin.PluginPriority.HIGH) diff --git a/plugins/keep/keep.py b/plugins/keep/keep.py index ef636765..d277ce77 100644 --- a/plugins/keep/keep.py +++ b/plugins/keep/keep.py @@ -8,7 +8,7 @@ PLUGIN_VERSION = "1.1" PLUGIN_API_VERSIONS = ["0.15.0", "0.15.1", "0.16.0", "1.0.0", "1.1.0", "1.2.0", "1.3.0", "2.0"] -PLUGIN_LICENSE = "GPL-2.0 or later" +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from picard.script import register_script_function diff --git a/plugins/lastfm/__init__.py b/plugins/lastfm/__init__.py index ee6d4a3a..e0c36a58 100644 --- a/plugins/lastfm/__init__.py +++ b/plugins/lastfm/__init__.py @@ -1,32 +1,38 @@ # -*- coding: utf-8 -*- PLUGIN_NAME = 'Last.fm' -PLUGIN_AUTHOR = 'Lukáš Lalinský' +PLUGIN_AUTHOR = 'Lukáš Lalinský, Philipp Wolfer' PLUGIN_DESCRIPTION = 'Use tags from Last.fm as genre.' -PLUGIN_VERSION = "0.5" +PLUGIN_VERSION = "0.9" PLUGIN_API_VERSIONS = ["2.0"] -from PyQt4 import QtCore -from picard.metadata import register_track_metadata_processor -from picard.ui.options import register_options_page, OptionsPage +import re +from functools import partial +from PyQt5 import QtCore +from picard import config, log from picard.config import BoolOption, IntOption, TextOption +from picard.metadata import register_track_metadata_processor from picard.plugins.lastfm.ui_options_lastfm import Ui_LastfmOptionsPage -from picard.util import partial -import traceback +from picard.ui.options import register_options_page, OptionsPage +from picard.util import build_qurl +from picard.webservice import ratecontrol -LASTFM_HOST = "ws.audioscrobbler.com" +LASTFM_HOST = 'ws.audioscrobbler.com' LASTFM_PORT = 80 +LASTFM_PATH = '/2.0/' +LASTFM_API_KEY = '0a210a4a6741f2ec8f27a791b9d5d971' -# From http://www.last.fm/api/tos, 2011-07-30 -# 4.4 (...) You will not make more than 5 requests per originating IP address per second, averaged over a -# 5 minute period, without prior written consent. (...) -from picard.webservice import ratecontrol +# From https://www.last.fm/api/tos, 2018-09-04 +# 4.4 […] You will not make more than 5 requests per originating IP address per +# second, averaged over a 5 minute period, without prior written consent. […] ratecontrol.set_minimum_delay((LASTFM_HOST, LASTFM_PORT), 200) # Cache for Tags to avoid re-requesting tags within same Picard session _cache = {} -# Keeps track of requests for tags made to webservice API but not yet returned (to avoid re-requesting the same URIs) -_pending_xmlws_requests = {} + +# Keeps track of requests for tags made to webservice API but not yet returned +# (to avoid re-requesting the same URIs) +_pending_requests = {} # TODO: move this to an options page TRANSLATE_TAGS = { @@ -37,22 +43,54 @@ TITLE_CASE = True -def _tags_finalize(album, metadata, tags, next): - if next: - next(tags) +def parse_ignored_tags(ignore_tags_setting): + ignore_tags = [] + for tag in ignore_tags_setting.lower().split(','): + tag = tag.strip() + if tag.startswith('/') and tag.endswith('/'): + try: + tag = re.compile(tag[1:-1]) + except re.error: + log.error( + 'Error parsing ignored tag "%s"', tag, exc_info=True) + ignore_tags.append(tag) + return ignore_tags + + +def matches_ignored(ignore_tags, tag): + tag = tag.lower().strip() + for pattern in ignore_tags: + if hasattr(pattern, 'match'): + match = pattern.match(tag) + else: + match = pattern == tag + if match: + return True + return False + + +def _tags_finalize(album, metadata, tags, next_): + if next_: + next_(tags) else: tags = list(set(tags)) if tags: - join_tags = album.tagger.config.setting["lastfm_join_tags"] + join_tags = config.setting["lastfm_join_tags"] if join_tags: tags = join_tags.join(tags) metadata["genre"] = tags -def _tags_downloaded(album, metadata, min_usage, ignore, next, current, data, reply, error): +def _tags_downloaded(album, metadata, min_usage, ignore, next_, current, data, + reply, error): + if error: + album._requests -= 1 + album._finalize_loading(None) + return + try: try: - intags = data.toptags[0].tag + intags = data.lfm[0].toptags[0].tag except AttributeError: intags = [] tags = [] @@ -68,80 +106,99 @@ def _tags_downloaded(album, metadata, min_usage, ignore, next, current, data, re name = TRANSLATE_TAGS[name] except KeyError: pass - if name.lower() not in ignore: + if not matches_ignored(ignore, name): tags.append(name.title()) - url = str(reply.url().path()) + url = reply.url().toString() _cache[url] = tags - _tags_finalize(album, metadata, current + tags, next) + _tags_finalize(album, metadata, current + tags, next_) # Process any pending requests for the same URL - if url in _pending_xmlws_requests: - pending = _pending_xmlws_requests[url] - del _pending_xmlws_requests[url] + if url in _pending_requests: + pending = _pending_requests[url] + del _pending_requests[url] for delayed_call in pending: delayed_call() - except: - album.tagger.log.error("Problem processing downloaded tags in last.fm plugin: %s", traceback.format_exc()) - raise + except Exception: + log.error('Problem processing download tags', exc_info=True) finally: album._requests -= 1 album._finalize_loading(None) -def get_tags(album, metadata, path, min_usage, ignore, next, current): +def get_tags(album, metadata, queryargs, min_usage, ignore, next_, current): """Get tags from an URL.""" - url = str(QtCore.QUrl.fromPercentEncoding(path)) + url = build_qurl( + LASTFM_HOST, LASTFM_PORT, LASTFM_PATH, queryargs).toString() if url in _cache: - _tags_finalize(album, metadata, current + _cache[url], next) + _tags_finalize(album, metadata, current + _cache[url], next_) else: - - # If we have already sent a request for this URL, delay this call until later - if url in _pending_xmlws_requests: - _pending_xmlws_requests[url].append(partial(get_tags, album, metadata, path, min_usage, ignore, next, current)) + # If we have already sent a request for this URL, delay this call + if url in _pending_requests: + _pending_requests[url].append( + partial(get_tags, album, metadata, queryargs, min_usage, + ignore, next_, current)) else: - _pending_xmlws_requests[url] = [] + _pending_requests[url] = [] album._requests += 1 - album.tagger.xmlws.get(LASTFM_HOST, LASTFM_PORT, path, - partial(_tags_downloaded, album, metadata, min_usage, ignore, next, current), - priority=True, important=True) + album.tagger.webservice.get( + LASTFM_HOST, LASTFM_PORT, LASTFM_PATH, + partial(_tags_downloaded, album, metadata, min_usage, ignore, + next_, current), + queryargs=queryargs, parse_response_type='xml', + priority=True, important=True) def encode_str(s): - # Yes, that's right, Last.fm prefers double URL-encoding s = QtCore.QUrl.toPercentEncoding(s) - s = QtCore.QUrl.toPercentEncoding(unicode(s)) - return s + return bytes(s).decode() + + +def get_queryargs(queryargs): + queryargs = {k: encode_str(v) for (k, v) in queryargs.items()} + queryargs['api_key'] = LASTFM_API_KEY + return queryargs -def get_track_tags(album, metadata, artist, track, min_usage, ignore, next, current): +def get_track_tags(album, metadata, artist, track, min_usage, + ignore, next_, current): """Get track top tags.""" - path = "/1.0/track/%s/%s/toptags.xml" % (encode_str(artist), encode_str(track)) - get_tags(album, metadata, path, min_usage, ignore, next, current) + queryargs = get_queryargs({ + 'method': 'Track.getTopTags', + 'artist': artist, + 'track': track, + }) + get_tags(album, metadata, queryargs, min_usage, ignore, next_, current) -def get_artist_tags(album, metadata, artist, min_usage, ignore, next, current): +def get_artist_tags(album, metadata, artist, min_usage, + ignore, next_, current): """Get artist top tags.""" - path = "/1.0/artist/%s/toptags.xml" % (encode_str(artist),) - get_tags(album, metadata, path, min_usage, ignore, next, current) + queryargs = get_queryargs({ + 'method': 'Artist.getTopTags', + 'artist': artist, + }) + get_tags(album, metadata, queryargs, min_usage, ignore, next_, current) def process_track(album, metadata, release, track): - tagger = album.tagger - use_track_tags = tagger.config.setting["lastfm_use_track_tags"] - use_artist_tags = tagger.config.setting["lastfm_use_artist_tags"] - min_tag_usage = tagger.config.setting["lastfm_min_tag_usage"] - ignore_tags = tagger.config.setting["lastfm_ignore_tags"].lower().split(",") + use_track_tags = config.setting["lastfm_use_track_tags"] + use_artist_tags = config.setting["lastfm_use_artist_tags"] + min_tag_usage = config.setting["lastfm_min_tag_usage"] + ignore_tags = parse_ignored_tags(config.setting["lastfm_ignore_tags"]) if use_track_tags or use_artist_tags: artist = metadata["artist"] title = metadata["title"] if artist: if use_artist_tags: - get_artist_tags_func = partial(get_artist_tags, album, metadata, artist, min_tag_usage, ignore_tags, None) + get_artist_tags_func = partial(get_artist_tags, album, + metadata, artist, min_tag_usage, + ignore_tags, None) else: get_artist_tags_func = None if title and use_track_tags: - get_track_tags(album, metadata, artist, title, min_tag_usage, ignore_tags, get_artist_tags_func, []) + get_track_tags(album, metadata, artist, title, min_tag_usage, + ignore_tags, get_artist_tags_func, []) elif get_artist_tags_func: get_artist_tags_func([]) @@ -155,8 +212,9 @@ class LastfmOptionsPage(OptionsPage): options = [ BoolOption("setting", "lastfm_use_track_tags", False), BoolOption("setting", "lastfm_use_artist_tags", False), - IntOption("setting", "lastfm_min_tag_usage", 15), - TextOption("setting", "lastfm_ignore_tags", "seen live,favorites"), + IntOption("setting", "lastfm_min_tag_usage", 90), + TextOption("setting", "lastfm_ignore_tags", + "seen live, favorites, /\\d+ of \\d+ stars/"), TextOption("setting", "lastfm_join_tags", ""), ] @@ -166,18 +224,24 @@ def __init__(self, parent=None): self.ui.setupUi(self) def load(self): - self.ui.use_track_tags.setChecked(self.config.setting["lastfm_use_track_tags"]) - self.ui.use_artist_tags.setChecked(self.config.setting["lastfm_use_artist_tags"]) - self.ui.min_tag_usage.setValue(self.config.setting["lastfm_min_tag_usage"]) - self.ui.ignore_tags.setText(self.config.setting["lastfm_ignore_tags"]) - self.ui.join_tags.setEditText(self.config.setting["lastfm_join_tags"]) + setting = config.setting + self.ui.use_track_tags.setChecked(setting["lastfm_use_track_tags"]) + self.ui.use_artist_tags.setChecked(setting["lastfm_use_artist_tags"]) + self.ui.min_tag_usage.setValue(setting["lastfm_min_tag_usage"]) + self.ui.ignore_tags.setText(setting["lastfm_ignore_tags"]) + self.ui.join_tags.setEditText(setting["lastfm_join_tags"]) def save(self): - self.config.setting["lastfm_use_track_tags"] = self.ui.use_track_tags.isChecked() - self.config.setting["lastfm_use_artist_tags"] = self.ui.use_artist_tags.isChecked() - self.config.setting["lastfm_min_tag_usage"] = self.ui.min_tag_usage.value() - self.config.setting["lastfm_ignore_tags"] = unicode(self.ui.ignore_tags.text()) - self.config.setting["lastfm_join_tags"] = unicode(self.ui.join_tags.currentText()) + global _cache + setting = config.setting + if setting["lastfm_min_tag_usage"] != self.ui.min_tag_usage.value() \ + or setting["lastfm_ignore_tags"] != str(self.ui.ignore_tags.text()): + _cache = {} + setting["lastfm_use_track_tags"] = self.ui.use_track_tags.isChecked() + setting["lastfm_use_artist_tags"] = self.ui.use_artist_tags.isChecked() + setting["lastfm_min_tag_usage"] = self.ui.min_tag_usage.value() + setting["lastfm_ignore_tags"] = str(self.ui.ignore_tags.text()) + setting["lastfm_join_tags"] = str(self.ui.join_tags.currentText()) register_track_metadata_processor(process_track) diff --git a/plugins/lastfm/options_lastfm.ui b/plugins/lastfm/options_lastfm.ui index 91a0aed1..02cbf46d 100644 --- a/plugins/lastfm/options_lastfm.ui +++ b/plugins/lastfm/options_lastfm.ui @@ -1,7 +1,8 @@ - + + LastfmOptionsPage - - + + 0 0 @@ -9,35 +10,53 @@ 317 - - + + + 6 + + 9 - - 6 + + 9 + + + 9 + + + 9 - - + + Last.fm - - + + + 2 + + 9 - - 2 + + 9 + + + 9 + + + 9 - - + + Use track tags - - + + Use artist tags @@ -46,75 +65,89 @@ - - + + Tags - - + + + 2 + + 9 - - 2 + + 9 + + + 9 + + + 9 - - + + Ignore tags: - + - - + + + 6 + + 0 - - 6 + + 0 + + + 0 + + + 0 - - - - 5 - 5 + + + 4 0 - + Join multiple tags with: - - - - 5 - 0 + + + 1 0 - + true - + - + / - + , @@ -123,37 +156,44 @@ - - + + + 6 + + 0 - - 6 + + 0 + + + 0 + + + 0 - - - - 7 - 5 + + + 0 0 - + Minimal tag usage: - + min_tag_usage - - + + % - + 100 @@ -165,10 +205,10 @@ - + Qt::Vertical - + 263 21 diff --git a/plugins/lastfm/ui_options_lastfm.py b/plugins/lastfm/ui_options_lastfm.py index 8bf3a5e6..203418c0 100644 --- a/plugins/lastfm/ui_options_lastfm.py +++ b/plugins/lastfm/ui_options_lastfm.py @@ -1,93 +1,91 @@ # -*- coding: utf-8 -*- -# Automatically generated - don't edit. -# Use `python setup.py build_ui` to update it. +# Form implementation generated from reading ui file 'options_lastfm.ui' +# +# Created by: PyQt5 UI code generator 5.11.2 +# +# WARNING! All changes made in this file will be lost! -from PyQt4 import QtCore, QtGui - -try: - _fromUtf8 = QtCore.QString.fromUtf8 -except AttributeError: - _fromUtf8 = lambda s: s +from PyQt5 import QtCore, QtGui, QtWidgets class Ui_LastfmOptionsPage(object): def setupUi(self, LastfmOptionsPage): - LastfmOptionsPage.setObjectName(_fromUtf8("LastfmOptionsPage")) + LastfmOptionsPage.setObjectName("LastfmOptionsPage") LastfmOptionsPage.resize(305, 317) - self.vboxlayout = QtGui.QVBoxLayout(LastfmOptionsPage) - self.vboxlayout.setMargin(9) + self.vboxlayout = QtWidgets.QVBoxLayout(LastfmOptionsPage) + self.vboxlayout.setContentsMargins(9, 9, 9, 9) self.vboxlayout.setSpacing(6) - self.vboxlayout.setObjectName(_fromUtf8("vboxlayout")) - self.rename_files = QtGui.QGroupBox(LastfmOptionsPage) - self.rename_files.setObjectName(_fromUtf8("rename_files")) - self.vboxlayout1 = QtGui.QVBoxLayout(self.rename_files) - self.vboxlayout1.setMargin(9) + self.vboxlayout.setObjectName("vboxlayout") + self.rename_files = QtWidgets.QGroupBox(LastfmOptionsPage) + self.rename_files.setObjectName("rename_files") + self.vboxlayout1 = QtWidgets.QVBoxLayout(self.rename_files) + self.vboxlayout1.setContentsMargins(9, 9, 9, 9) self.vboxlayout1.setSpacing(2) - self.vboxlayout1.setObjectName(_fromUtf8("vboxlayout1")) - self.use_track_tags = QtGui.QCheckBox(self.rename_files) - self.use_track_tags.setObjectName(_fromUtf8("use_track_tags")) + self.vboxlayout1.setObjectName("vboxlayout1") + self.use_track_tags = QtWidgets.QCheckBox(self.rename_files) + self.use_track_tags.setObjectName("use_track_tags") self.vboxlayout1.addWidget(self.use_track_tags) - self.use_artist_tags = QtGui.QCheckBox(self.rename_files) - self.use_artist_tags.setObjectName(_fromUtf8("use_artist_tags")) + self.use_artist_tags = QtWidgets.QCheckBox(self.rename_files) + self.use_artist_tags.setObjectName("use_artist_tags") self.vboxlayout1.addWidget(self.use_artist_tags) self.vboxlayout.addWidget(self.rename_files) - self.rename_files_2 = QtGui.QGroupBox(LastfmOptionsPage) - self.rename_files_2.setObjectName(_fromUtf8("rename_files_2")) - self.vboxlayout2 = QtGui.QVBoxLayout(self.rename_files_2) - self.vboxlayout2.setMargin(9) + self.rename_files_2 = QtWidgets.QGroupBox(LastfmOptionsPage) + self.rename_files_2.setObjectName("rename_files_2") + self.vboxlayout2 = QtWidgets.QVBoxLayout(self.rename_files_2) + self.vboxlayout2.setContentsMargins(9, 9, 9, 9) self.vboxlayout2.setSpacing(2) - self.vboxlayout2.setObjectName(_fromUtf8("vboxlayout2")) - self.ignore_tags_2 = QtGui.QLabel(self.rename_files_2) - self.ignore_tags_2.setObjectName(_fromUtf8("ignore_tags_2")) + self.vboxlayout2.setObjectName("vboxlayout2") + self.ignore_tags_2 = QtWidgets.QLabel(self.rename_files_2) + self.ignore_tags_2.setObjectName("ignore_tags_2") self.vboxlayout2.addWidget(self.ignore_tags_2) - self.ignore_tags = QtGui.QLineEdit(self.rename_files_2) - self.ignore_tags.setObjectName(_fromUtf8("ignore_tags")) + self.ignore_tags = QtWidgets.QLineEdit(self.rename_files_2) + self.ignore_tags.setObjectName("ignore_tags") self.vboxlayout2.addWidget(self.ignore_tags) - self.hboxlayout = QtGui.QHBoxLayout() - self.hboxlayout.setMargin(0) + self.hboxlayout = QtWidgets.QHBoxLayout() + self.hboxlayout.setContentsMargins(0, 0, 0, 0) self.hboxlayout.setSpacing(6) - self.hboxlayout.setObjectName(_fromUtf8("hboxlayout")) - self.ignore_tags_4 = QtGui.QLabel(self.rename_files_2) - sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Policy(5), QtGui.QSizePolicy.Policy(5)) + self.hboxlayout.setObjectName("hboxlayout") + self.ignore_tags_4 = QtWidgets.QLabel(self.rename_files_2) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(4) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.ignore_tags_4.sizePolicy().hasHeightForWidth()) self.ignore_tags_4.setSizePolicy(sizePolicy) - self.ignore_tags_4.setObjectName(_fromUtf8("ignore_tags_4")) + self.ignore_tags_4.setObjectName("ignore_tags_4") self.hboxlayout.addWidget(self.ignore_tags_4) - self.join_tags = QtGui.QComboBox(self.rename_files_2) - sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Policy(5), QtGui.QSizePolicy.Policy(0)) + self.join_tags = QtWidgets.QComboBox(self.rename_files_2) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(1) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.join_tags.sizePolicy().hasHeightForWidth()) self.join_tags.setSizePolicy(sizePolicy) self.join_tags.setEditable(True) - self.join_tags.setObjectName(_fromUtf8("join_tags")) - self.join_tags.addItem(_fromUtf8("")) - self.join_tags.setItemText(0, _fromUtf8("")) - self.join_tags.addItem(_fromUtf8("")) - self.join_tags.addItem(_fromUtf8("")) + self.join_tags.setObjectName("join_tags") + self.join_tags.addItem("") + self.join_tags.setItemText(0, "") + self.join_tags.addItem("") + self.join_tags.addItem("") self.hboxlayout.addWidget(self.join_tags) self.vboxlayout2.addLayout(self.hboxlayout) - self.hboxlayout1 = QtGui.QHBoxLayout() - self.hboxlayout1.setMargin(0) + self.hboxlayout1 = QtWidgets.QHBoxLayout() + self.hboxlayout1.setContentsMargins(0, 0, 0, 0) self.hboxlayout1.setSpacing(6) - self.hboxlayout1.setObjectName(_fromUtf8("hboxlayout1")) - self.label_4 = QtGui.QLabel(self.rename_files_2) - sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Policy(7), QtGui.QSizePolicy.Policy(5)) + self.hboxlayout1.setObjectName("hboxlayout1") + self.label_4 = QtWidgets.QLabel(self.rename_files_2) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.label_4.sizePolicy().hasHeightForWidth()) self.label_4.setSizePolicy(sizePolicy) - self.label_4.setObjectName(_fromUtf8("label_4")) + self.label_4.setObjectName("label_4") self.hboxlayout1.addWidget(self.label_4) - self.min_tag_usage = QtGui.QSpinBox(self.rename_files_2) + self.min_tag_usage = QtWidgets.QSpinBox(self.rename_files_2) self.min_tag_usage.setMaximum(100) - self.min_tag_usage.setObjectName(_fromUtf8("min_tag_usage")) + self.min_tag_usage.setObjectName("min_tag_usage") self.hboxlayout1.addWidget(self.min_tag_usage) self.vboxlayout2.addLayout(self.hboxlayout1) self.vboxlayout.addWidget(self.rename_files_2) - spacerItem = QtGui.QSpacerItem(263, 21, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + spacerItem = QtWidgets.QSpacerItem(263, 21, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) self.vboxlayout.addItem(spacerItem) self.label_4.setBuddy(self.min_tag_usage) @@ -96,14 +94,24 @@ def setupUi(self, LastfmOptionsPage): LastfmOptionsPage.setTabOrder(self.use_track_tags, self.ignore_tags) def retranslateUi(self, LastfmOptionsPage): - self.rename_files.setTitle(_("Last.fm")) - self.use_track_tags.setText(_("Use track tags")) - self.use_artist_tags.setText(_("Use artist tags")) - self.rename_files_2.setTitle(_("Tags")) - self.ignore_tags_2.setText(_("Ignore tags:")) - self.ignore_tags_4.setText(_("Join multiple tags with:")) - self.join_tags.setItemText(1, _(" / ")) - self.join_tags.setItemText(2, _(", ")) - self.label_4.setText(_("Minimal tag usage:")) - self.min_tag_usage.setSuffix(_(" %")) + _translate = QtCore.QCoreApplication.translate + self.rename_files.setTitle(_translate("LastfmOptionsPage", "Last.fm")) + self.use_track_tags.setText(_translate("LastfmOptionsPage", "Use track tags")) + self.use_artist_tags.setText(_translate("LastfmOptionsPage", "Use artist tags")) + self.rename_files_2.setTitle(_translate("LastfmOptionsPage", "Tags")) + self.ignore_tags_2.setText(_translate("LastfmOptionsPage", "Ignore tags:")) + self.ignore_tags_4.setText(_translate("LastfmOptionsPage", "Join multiple tags with:")) + self.join_tags.setItemText(1, _translate("LastfmOptionsPage", " / ")) + self.join_tags.setItemText(2, _translate("LastfmOptionsPage", ", ")) + self.label_4.setText(_translate("LastfmOptionsPage", "Minimal tag usage:")) + self.min_tag_usage.setSuffix(_translate("LastfmOptionsPage", " %")) + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + LastfmOptionsPage = QtWidgets.QWidget() + ui = Ui_LastfmOptionsPage() + ui.setupUi(LastfmOptionsPage) + LastfmOptionsPage.show() + sys.exit(app.exec_()) diff --git a/plugins/lastfmplus/__init__.py b/plugins/lastfmplus/__init__.py deleted file mode 100644 index 4e94c244..00000000 --- a/plugins/lastfmplus/__init__.py +++ /dev/null @@ -1,748 +0,0 @@ -# -*- coding: utf-8 -*- - -PLUGIN_NAME = 'Last.fm.Plus' -PLUGIN_AUTHOR = 'RifRaf, Lukáš Lalinský, voiceinsideyou' -PLUGIN_DESCRIPTION = '''Uses folksonomy tags from Last.fm to
-* Sort music into major and minor genres based on configurable genre "whitelists"
-* Add "mood", "occasion" and other custom categories
-* Add "original release year" and "decade" tags, as well as populate blank dates.''' -PLUGIN_VERSION = "0.15" -PLUGIN_API_VERSIONS = ["2.0"] - -from PyQt4 import QtGui, QtCore -from picard.metadata import register_track_metadata_processor -from picard.ui.options import register_options_page, OptionsPage -from picard.config import BoolOption, IntOption, TextOption -from picard.plugins.lastfmplus.ui_options_lastfm import Ui_LastfmOptionsPage -from picard.util import partial -import traceback -import re - -LASTFM_HOST = "ws.audioscrobbler.com" -LASTFM_PORT = 80 - -# From http://www.last.fm/api/tos, 2011-07-30 -# 4.4 (...) You will not make more than 5 requests per originating IP address per second, averaged over a -# 5 minute period, without prior written consent. (...) -from picard.webservice import ratecontrol -ratecontrol.set_minimum_delay((LASTFM_HOST, LASTFM_PORT), 200) - -# Cache for Tags to avoid re-requesting tags within same Picard session -_cache = {} -# Keeps track of requests for tags made to webservice API but not yet returned (to avoid re-requesting the same URIs) -_pending_xmlws_requests = {} - -# Cache to Find the Genres and other Tags -ALBUM_GENRE = {} -ALBUM_SUBGENRE = {} -ALBUM_COUNTRY = {} -ALBUM_CITY = {} -ALBUM_DECADE = {} -ALBUM_YEAR = {} -ALBUM_OCCASION = {} -ALBUM_CATEGORY = {} -ALBUM_MOOD = {} - -#noinspection PyDictCreation -GENRE_FILTER = {} -GENRE_FILTER["_loaded_"] = False -GENRE_FILTER["major"] = ["audiobooks, blues, classic rock, classical, country, dance, electronica, folk, hip-hop, indie, jazz, kids, metal, pop, punk, reggae, rock, soul, trance"] -GENRE_FILTER["minor"] = ["2 tone, a cappella, abstract hip-hop, acid, acid jazz, acid rock, acoustic, acoustic guitar, acoustic rock, adult alternative, adult contemporary, alternative, alternative country, alternative folk, alternative metal, alternative pop, alternative rock, ambient, anti-folk, art rock, atmospheric, aussie hip-hop, avant-garde, ballads, baroque, beach, beats, bebop, big band, blaxploitation, blue-eyed soul, bluegrass, blues rock, boogie rock, boogie woogie, bossa nova, breakbeat, breaks, brit pop, brit rock, british invasion, broadway, bubblegum pop, cabaret, calypso, cha cha, choral, christian rock, classic country, classical guitar, club, college rock, composers, contemporary country, contemporary folk, country folk, country pop, country rock, crossover, dance pop, dancehall, dark ambient, darkwave, delta blues, dirty south, disco, doo wop, doom metal, downtempo, dream pop, drum and bass, dub, dub reggae, dubstep, east coast rap, easy listening, electric blues, electro, electro pop, elevator music, emo, emocore, ethnic, eurodance, europop, experimental, fingerstyle, folk jazz, folk pop, folk punk, folk rock, folksongs, free jazz, french rap, funk, funk metal, funk rock, fusion, g-funk, gaelic, gangsta rap, garage, garage rock, glam rock, goa trance, gospel, gothic, gothic metal, gothic rock, gregorian, groove, grunge, guitar, happy hardcore, hard rock, hardcore, hardcore punk, hardcore rap, hardstyle, heavy metal, honky tonk, horror punk, house, humour, hymn, idm, indie folk, indie pop, indie rock, industrial, industrial metal, industrial rock, instrumental, instrumental hip-hop, instrumental rock, j-pop, j-rock, jangle pop, jazz fusion, jazz vocal, jungle, latin, latin jazz, latin pop, lounge, lovers rock, lullaby, madchester, mambo, medieval, melodic rock, minimal, modern country, modern rock, mood music, motown, neo-soul, new age, new romantic, new wave, noise, northern soul, nu metal, old school rap, opera, orchestral, philly soul, piano, political reggae, polka, pop life, pop punk, pop rock, pop soul, post punk, post rock, power pop, progressive, progressive rock, psychedelic, psychedelic folk, psychedelic punk, psychedelic rock, psychobilly, psytrance, punk rock, quiet storm, r&b, ragga, rap, rap metal, reggae pop, reggae rock, rock and roll, rock opera, rockabilly, rocksteady, roots, roots reggae, rumba, salsa, samba, screamo, shock rock, shoegaze, ska, ska punk, smooth jazz, soft rock, southern rock, space rock, spoken word, standards, stoner rock, surf rock, swamp rock, swing, symphonic metal, symphonic rock, synth pop, tango, techno, teen pop, thrash metal, traditional country, traditional folk, tribal, trip-hop, turntablism, underground, underground hip-hop, underground rap, urban, vocal trance, waltz, west coast rap, western swing, world, world fusion"] -GENRE_FILTER["country"] = ["african, american, arabic, australian, austrian, belgian, brazilian, british, canadian, caribbean, celtic, chinese, cuban, danish, dutch, eastern europe, egyptian, estonian, european, finnish, french, german, greek, hawaiian, ibiza, icelandic, indian, iranian, irish, island, israeli, italian, jamaican, japanese, korean, mexican, middle eastern, new zealand, norwegian, oriental, polish, portuguese, russian, scandinavian, scottish, southern, spanish, swedish, swiss, thai, third world, turkish, welsh, western"] -GENRE_FILTER["city"] = ["acapulco, adelaide, amsterdam, athens, atlanta, atlantic city, auckland, austin, bakersfield, bali, baltimore, bangalore, bangkok, barcelona, barrie, beijing, belfast, berlin, birmingham, bogota, bombay, boston, brasilia, brisbane, bristol, brooklyn, brussels, bucharest, budapest, buenos aires, buffalo, calcutta, calgary, california, cancun, caracas, charlotte, chicago, cincinnati, cleveland, copenhagen, dallas, delhi, denver, detroit, dublin, east coast, edmonton, frankfurt, geneva, glasgow, grand rapids, guadalajara, halifax, hamburg, hamilton, helsinki, hong kong, houston, illinois, indianapolis, istanbul, jacksonville, kansas city, kiev, las vegas, leeds, lisbon, liverpool, london, los angeles, louisville, madrid, manchester, manila, marseille, mazatlan, melbourne, memphis, mexico city, miami, michigan, milan, minneapolis, minnesota, mississippi, monterrey, montreal, munich, myrtle beach, nashville, new jersey, new orleans, new york, new york city, niagara falls, omaha, orlando, oslo, ottawa, palm springs, paris, pennsylvania, perth, philadelphia, phoenix, phuket, pittsburgh, portland, puebla, raleigh, reno, richmond, rio de janeiro, rome, sacramento, salt lake city, san antonio, san diego, san francisco, san jose, santiago, sao paulo, seattle, seoul, shanghai, sheffield, spokane, stockholm, sydney, taipei, tampa, texas, tijuana, tokyo, toledo, toronto, tucson, tulsa, vancouver, victoria, vienna, warsaw, wellington, westcoast, windsor, winnipeg, zurich"] -GENRE_FILTER["mood"] = ["angry, bewildered, bouncy, calm, cheerful, chill, cold, complacent, crazy, crushed, cynical, depressed, dramatic, dreamy, drunk, eclectic, emotional, energetic, envious, feel good, flirty, funky, groovy, happy, haunting, healing, high, hopeful, hot, humorous, inspiring, intense, irritated, laidback, lonely, lovesongs, meditation, melancholic, melancholy, mellow, moody, morose, passionate, peace, peaceful, playful, pleased, positive, quirky, reflective, rejected, relaxed, retro, sad, sentimental, sexy, silly, smooth, soulful, spiritual, suicidal, surprised, sympathetic, trippy, upbeat, uplifting, weird, wild, yearning"] -GENRE_FILTER["decade"] = ["1800s, 1810s, 1820s, 1830s, 1980s, 1850s, 1860s, 1870s, 1880s, 1890s, 1900s, 1910s, 1920s, 1930s, 1940s, 1950s, 1960s, 1970s, 1980s, 1990s, 2000s"] -GENRE_FILTER["year"] = ["1801, 1802, 1803, 1804, 1805, 1806, 1807, 1808, 1809, 1810, 1811, 1812, 1813, 1814, 1815, 1816, 1817, 1818, 1819, 1820, 1821, 1822, 1823, 1824, 1825, 1826, 1827, 1828, 1829, 1830, 1831, 1832, 1833, 1834, 1835, 1836, 1837, 1838, 1839, 1840, 1841, 1842, 1843, 1844, 1845, 1846, 1847, 1848, 1849, 1850, 1851, 1852, 1853, 1854, 1855, 1856, 1857, 1858, 1859, 1860, 1861, 1862, 1863, 1864, 1865, 1866, 1867, 1868, 1869, 1870, 1871, 1872, 1873, 1874, 1875, 1876, 1877, 1878, 1879, 1880, 1881, 1882, 1883, 1884, 1885, 1886, 1887, 1888, 1889, 1890, 1891, 1892, 1893, 1894, 1895, 1896, 1897, 1898, 1899, 1900, 1901, 1902, 1903, 1904, 1905, 1906, 1907, 1908, 1909, 1910, 1911, 1912, 1913, 1914, 1915, 1916, 1917, 1918, 1919, 1920, 1921, 1922, 1923, 1924, 1925, 1926, 1927, 1928, 1929, 1930, 1931, 1932, 1933, 1934, 1935, 1936, 1937, 1938, 1939, 1940, 1941, 1942, 1943, 1944, 1945, 1946, 1947, 1948, 1949, 1950, 1951, 1952, 1953, 1954, 1955, 1956, 1957, 1958, 1959, 1960, 1961, 1962, 1963, 1964, 1965, 1966, 1967, 1968, 1969, 1970, 1971, 1972, 1973, 1974, 1975, 1976, 1977, 1978, 1979, 1980, 1981, 1982, 1983, 1984, 1985, 1986, 1987, 1988, 1989, 1990, 1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020"] -GENRE_FILTER["occasion"] = ["background, birthday, breakup, carnival, chillout, christmas, death, dinner, drinking, driving, graduation, halloween, hanging out, heartache, holiday, late night, love, new year, party, protest, rain, rave, romantic, sleep, spring, summer, sunny, twilight, valentine, wake up, wedding, winter, work"] -GENRE_FILTER["category"] = ["animal songs, attitude, autumn, b-side, ballad, banjo, bass, beautiful, body parts, bootlegs, brass, cafe del mar, chamber music, clarinet, classic, classic tunes, compilations, covers, cowbell, deceased, demos, divas, dj, drugs, drums, duets, field recordings, female, female vocalists, film score, flute, food, genius, girl group, great lyrics, guitar solo, guitarist, handclaps, harmonica, historical, horns, hypnotic, influential, insane, jam, keyboard, legends, life, linedance, live, loved, lyricism, male, male vocalists, masterpiece, melodic, memories, musicals, nostalgia, novelty, number songs, old school, oldie, oldies, one hit wonders, orchestra, organ, parody, poetry, political, promos, radio programs, rastafarian, remix, samples, satire, saxophone, showtunes, sing-alongs, singer-songwriter, slide guitar, solo instrumentals, songs with names, soundtracks, speeches, stories, strings, stylish, synth, title is a full sentence, top 40, traditional, trumpet, unique, unplugged, violin, virtuoso, vocalization, vocals"] -GENRE_FILTER["translate"] = { - "drum 'n' bass": "drum and bass", - "drum n bass": "drum and bass" -} - - -def matches_list(s, lst): - if s in lst: - return True - for item in lst: - if '*' in item: - if re.match(re.escape(item).replace(r'\*', '.*?'), s): - return True - return False - -# Function to sort/compare a 2 Element of Tupel - - -def cmp1(a, b): - return cmp(a[1], b[1]) * -1 -# Special Compare/Sort-Function to sort downloaded Tags - - -def cmptaginfo(a, b): - return cmp(a[1][0], b[1][0]) * -1 - - -def _lazy_load_filters(cfg): - if not GENRE_FILTER["_loaded_"]: - GENRE_FILTER["major"] = cfg["lastfm_genre_major"].split(',') - GENRE_FILTER["minor"] = cfg["lastfm_genre_minor"].split(',') - GENRE_FILTER["decade"] = cfg["lastfm_genre_decade"].split(',') - GENRE_FILTER["year"] = cfg["lastfm_genre_year"].split(',') - GENRE_FILTER["country"] = cfg["lastfm_genre_country"].split(',') - GENRE_FILTER["city"] = cfg["lastfm_genre_city"].split(',') - GENRE_FILTER["mood"] = cfg["lastfm_genre_mood"].split(',') - GENRE_FILTER["occasion"] = cfg["lastfm_genre_occasion"].split(',') - GENRE_FILTER["category"] = cfg["lastfm_genre_category"].split(',') - GENRE_FILTER["translate"] = dict([item.split(',') for item in cfg["lastfm_genre_translations"].split("\n")]) - GENRE_FILTER["_loaded_"] = True - - -def apply_translations_and_sally(tag_to_count, sally, factor): - ret = {} - for name, count in tag_to_count.iteritems(): - # apply translations - try: - name = GENRE_FILTER["translate"][name.lower()] - except KeyError: - pass - - # make sure it's lowercase - lower = name.lower() - - if lower not in ret or ret[lower][0] < (count * factor): - ret[lower] = [count * factor, sally] - return ret.items() - - -def _tags_finalize(album, metadata, tags, next): - """Processes the tag metadata to decide which tags to use and sets metadata""" - - if next: - next(tags) - else: - cfg = album.tagger.config.setting - - # last tag-weight for inter-tag comparsion - lastw = {"n": False, "s": False} - # List: (use sally-tags, use track-tags, use artist-tags, use - # drop-info,use minweight,searchlist, max_elems - info = {"major" : [True, True, True, True, True, GENRE_FILTER["major"], cfg["lastfm_max_group_tags"]], - "minor" : [True, True, True, True, True, GENRE_FILTER["minor"], cfg["lastfm_max_minor_tags"]], - "country" : [True, False, True, False, False, GENRE_FILTER["country"], 1], - "city" : [True, False, True, False, False, GENRE_FILTER["city"], 1], - "decade" : [True, True, False, False, False, GENRE_FILTER["decade"], 1], - "year" : [True, True, False, False, False, GENRE_FILTER["year"], 1], - "year2" : [True, True, False, False, False, GENRE_FILTER["year"], 1], - "year3" : [True, True, False, False, False, GENRE_FILTER["year"], 1], - "mood" : [True, True, True, False, False, GENRE_FILTER["mood"], cfg["lastfm_max_mood_tags"]], - "occasion": [True, True, True, False, False, GENRE_FILTER["occasion"], cfg["lastfm_max_occasion_tags"]], - "category": [True, True, True, False, False, GENRE_FILTER["category"], cfg["lastfm_max_category_tags"]] - } - hold = {"all/tags": []} - - # Init the Album-Informations - albid = album.id - if cfg["write_id3v23"]: - year_tag = '~id3:TORY' - else: - year_tag = '~id3:TDOR' - glb = {"major" : {'metatag' : 'grouping', 'data' : ALBUM_GENRE}, - "country" : {'metatag' : 'comment:Songs-DB_Custom3', 'data' : ALBUM_COUNTRY}, - "city" : {'metatag' : 'comment:Songs-DB_Custom3', 'data' : ALBUM_CITY}, - "year" : {'metatag' : year_tag, 'data' : ALBUM_YEAR}, - "year2" : {'metatag' : 'originalyear', 'data' : ALBUM_YEAR}, - "year3" : {'metatag' : 'date', 'data' : ALBUM_YEAR} } - for elem in glb.keys(): - if not albid in glb[elem]['data']: - glb[elem]['data'][albid] = {'count': 1, 'genres': {}} - else: - glb[elem]['data'][albid]['count'] += 1 - - if tags: - # search for tags - tags.sort(cmp=cmptaginfo) - for lowered, [weight, stype] in tags: - name = lowered.title() - # if is tag which should only used for extension (if too few - # tags found) - s = stype == 1 - arttag = stype > 0 # if is artist tag - if not name in hold["all/tags"]: - hold["all/tags"].append(name) - - # Decide if tag should be searched in major and minor fields - drop = not (s and (not lastw['s'] or (lastw['s'] - weight) < cfg["lastfm_max_artisttag_drop"])) and not ( - not s and (not lastw['n'] or (lastw['n'] - weight) < cfg["lastfm_max_tracktag_drop"])) - if not drop: - if s: - lastw['s'] = weight - else: - lastw['n'] = weight - - below = (s and weight < cfg["lastfm_min_artisttag_weight"]) or ( - not s and weight < cfg["lastfm_min_tracktag_weight"]) - - for group, ielem in info.items(): - if matches_list(lowered, ielem[5]): - if below and ielem[4]: - # If Should use min-weigh information - break - if drop and ielem[3]: - # If Should use the drop-information - break - if s and not ielem[0]: - # If Sally-Tag and should not be used - break - if arttag and not ielem[2]: - # If Artist-Tag and should not be used - break - if not arttag and not ielem[1]: - # If Track-Tag and should not be used - break - - # prefer Not-Sally-Tags (so, artist OR track-tags) - if not s and group + "/sally" in hold and name in hold[group + "/sally"]: - hold[group + "/sally"].remove(name) - hold[group + "/tags"].remove(name) - # Insert Tag - if not group + "/tags" in hold: - hold[group + "/tags"] = [] - if not name in hold[group + "/tags"]: - if s: - if not group + "/sally" in hold: - hold[group + "/sally"] = [] - hold[group + "/sally"].append(name) - # collect global genre information for special - # tag-filters - if not arttag and group in glb: - if not name in glb[group]['data'][albid]['genres']: - glb[group]['data'][albid][ - 'genres'][name] = weight - else: - glb[group]['data'][albid][ - 'genres'][name] += weight - # append tag - hold[group + "/tags"].append(name) - # Break becase every Tag should be faced only by one - # GENRE_FILTER - break - - # cut to wanted size - for group, ielem in info.items(): - while group + "/tags" in hold and len(hold[group + "/tags"]) > ielem[6]: - # Remove first all Sally-Tags - if group + "/sally" in hold and len(hold[group + "/sally"]) > 0: - deltag = hold[group + "/sally"].pop() - hold[group + "/tags"].remove(deltag) - else: - hold[group + "/tags"].pop() - - # join the information - join_tags = cfg["lastfm_join_tags_sign"] - - def join_tags_or_not(list): - if join_tags: - return join_tags.join(list) - return list - if 1: - used = [] - - # write the major-tags - if "major/tags" in hold and len(hold["major/tags"]) > 0: - metadata["grouping"] = join_tags_or_not(hold["major/tags"]) - used.extend(hold["major/tags"]) - - # write the decade-tags - if "decade/tags" in hold and len(hold["decade/tags"]) > 0 and cfg["lastfm_use_decade_tag"]: - metadata["comment:Songs-DB_Custom1"] = join_tags_or_not( - [item.lower() for item in hold["decade/tags"]]) - used.extend(hold["decade/tags"]) - - # write country tag - if "country/tags" in hold and len(hold["country/tags"]) > 0 and "city/tags" in hold and len(hold["city/tags"]) > 0 and cfg["lastfm_use_country_tag"] and cfg["lastfm_use_city_tag"]: - metadata["comment:Songs-DB_Custom3"] = join_tags_or_not( - hold["country/tags"] + hold["city/tags"]) - used.extend(hold["country/tags"]) - used.extend(hold["city/tags"]) - elif "country/tags" in hold and len(hold["country/tags"]) > 0 and cfg["lastfm_use_country_tag"]: - metadata["comment:Songs-DB_Custom3"] = join_tags_or_not( - hold["country/tags"]) - used.extend(hold["country/tags"]) - elif "city/tags" in hold and len(hold["city/tags"]) > 0 and cfg["lastfm_use_city_tag"]: - metadata["comment:Songs-DB_Custom3"] = join_tags_or_not( - hold["city/tags"]) - used.extend(hold["city/tags"]) - - # write the mood-tags - if "mood/tags" in hold and len(hold["mood/tags"]) > 0: - metadata["mood"] = join_tags_or_not(hold["mood/tags"]) - used.extend(hold["mood/tags"]) - - # write the occasion-tags - if "occasion/tags" in hold and len(hold["occasion/tags"]) > 0: - metadata["comment:Songs-DB_Occasion"] = join_tags_or_not( - hold["occasion/tags"]) - used.extend(hold["occasion/tags"]) - - # write the category-tags - if "category/tags" in hold and len(hold["category/tags"]) > 0: - metadata["comment:Songs-DB_Custom2"] = join_tags_or_not( - hold["category/tags"]) - used.extend(hold["category/tags"]) - - # include major tags as minor tags also copy major to minor if - # no minor genre - if cfg["lastfm_app_major2minor_tag"] and "major/tags" in hold and "minor/tags" in hold and len(hold["minor/tags"]) > 0: - used.extend(hold["major/tags"]) - used.extend(hold["minor/tags"]) - if len(used) > 0: - metadata["genre"] = join_tags_or_not( - hold["major/tags"] + hold["minor/tags"]) - elif cfg["lastfm_app_major2minor_tag"] and "major/tags" in hold and "minor/tags" not in hold: - used.extend(hold["major/tags"]) - if len(used) > 0: - metadata["genre"] = join_tags_or_not( - hold["major/tags"]) - elif "minor/tags" in hold and len(hold["minor/tags"]) > 0: - metadata["genre"] = join_tags_or_not( - hold["minor/tags"]) - used.extend(hold["minor/tags"]) - else: - if "minor/tags" not in hold and "major/tags" in hold: - metadata["genre"] = metadata["grouping"] - - # replace blank original year with release date - if cfg["lastfm_use_year_tag"]: - if "year/tags" not in hold and len(metadata["date"]) > 0: - metadata["originalyear"] = metadata["date"][:4] - if cfg["write_id3v23"]: - metadata["~id3:TORY"] = metadata["date"][:4] - #album.tagger.log.info('TORY: %r', metadata["~id3:TORY"]) - else: - metadata["~id3:TDOR"] = metadata["date"][:4] - #album.tagger.log.info('TDOR: %r', metadata["~id3:TDOR"]) - if metadata["originalyear"] > metadata["date"][:4]: - metadata["originalyear"] = metadata["date"][:4] - if metadata["~id3:TDOR"] > metadata["date"][:4] and not cfg["write_id3v23"]: - metadata["~id3:TDOR"] = metadata["date"][:4] - if metadata["~id3:TORY"] > metadata["date"][:4] and cfg["write_id3v23"]: - metadata["~id3:TORY"] = metadata["date"][:4] - # Replace blank decades - if "decade/tags" not in hold and len(metadata["originalyear"])>0 and int(metadata["originalyear"])>1999 and cfg["lastfm_use_decade_tag"]: - metadata["comment:Songs-DB_Custom1"] = "20%s0s" % str(metadata["originalyear"])[2] - elif "decade/tags" not in hold and len(metadata["originalyear"])>0 and int(metadata["originalyear"])<2000 and int(metadata["originalyear"])>1899 and cfg["lastfm_use_decade_tag"]: - metadata["comment:Songs-DB_Custom1"] = "19%s0s" % str(metadata["originalyear"])[2] - elif "decade/tags" not in hold and len(metadata["originalyear"])>0 and int(metadata["originalyear"])<1900 and int(metadata["originalyear"])>1799 and cfg["lastfm_use_decade_tag"]: - metadata["comment:Songs-DB_Custom1"] = "18%s0s" % str(metadata["originalyear"])[2] - - -def _tags_downloaded(album, metadata, sally, factor, next, current, data, reply, error): - try: - - try: - intags = data.toptags[0].tag - except AttributeError: - intags = [] - - # Extract just names and counts from response; apply no parsing at this stage - tag_to_count = {} - for tag in intags: - # name of the tag - name = tag.name[0].text.strip() - - # count of the tag - try: - count = int(tag.count[0].text.strip()) - except ValueError: - count = 0 - - tag_to_count[name] = count - - url = str(reply.url().path()) - _cache[url] = tag_to_count - - tags = apply_translations_and_sally(tag_to_count, sally, factor) - - _tags_finalize(album, metadata, current + tags, next) - - # Process any pending requests for the same URL - if url in _pending_xmlws_requests: - pending = _pending_xmlws_requests[url] - del _pending_xmlws_requests[url] - for delayed_call in pending: - delayed_call() - - except: - album.tagger.log.error("Problem processing downloaded tags in last.fm plus plugin: %s", traceback.format_exc()) - raise - finally: - album._requests -= 1 - album._finalize_loading(None) - - -def get_tags(album, metadata, path, sally, factor, next, current): - """Get tags from an URL.""" - - # Ensure config is loaded (or reloaded if has been changed) - _lazy_load_filters(album.tagger.config.setting) - - url = str(QtCore.QUrl.fromPercentEncoding(path)) - if url in _cache: - tags = apply_translations_and_sally(_cache[url], sally, factor) - _tags_finalize(album, metadata, current + tags, next) - else: - - # If we have already sent a request for this URL, delay this call until later - if url in _pending_xmlws_requests: - _pending_xmlws_requests[url].append(partial(get_tags, album, metadata, path, sally, factor, next, current)) - else: - _pending_xmlws_requests[url] = [] - album._requests += 1 - album.tagger.xmlws.get(LASTFM_HOST, LASTFM_PORT, path, - partial(_tags_downloaded, album, metadata, sally, factor, next, current), - priority=True, important=True) - - -def encode_str(s): - # Yes, that's right, Last.fm prefers double URL-encoding - s = QtCore.QUrl.toPercentEncoding(s) - s = QtCore.QUrl.toPercentEncoding(unicode(s)) - return s - - -def get_track_tags(album, metadata, artist, track, next, current): - path = "/1.0/track/%s/%s/toptags.xml" % (encode_str(artist), encode_str(track)) - sally = 0 - factor = 1.0 - return get_tags(album, metadata, path, sally, factor, next, current) - - -def get_artist_tags(album, metadata, artist, next, current): - path = "/1.0/artist/%s/toptags.xml" % encode_str(artist) - sally = 2 - if album.tagger.config.setting["lastfm_artist_tag_us_ex"]: - sally = 1 - factor = album.tagger.config.setting["lastfm_artist_tags_weight"] / 100.0 - return get_tags(album, metadata, path, sally, factor, next, current) - - -def process_track(album, metadata, release, track): - tagger = album.tagger - use_track_tags = tagger.config.setting["lastfm_use_track_tags"] - use_artist_tags = tagger.config.setting["lastfm_artist_tag_us_ex"] or tagger.config.setting["lastfm_artist_tag_us_yes"] - - if use_track_tags or use_artist_tags: - artist = metadata["artist"] - title = metadata["title"] - if artist: - if use_artist_tags: - get_artist_tags_func = partial(get_artist_tags, album, metadata, artist, None) - else: - get_artist_tags_func = None - if title and use_track_tags: - get_track_tags(album, metadata, artist, title, get_artist_tags_func, []) - elif get_artist_tags_func: - get_artist_tags_func([]) - - -class LastfmOptionsPage(OptionsPage): - NAME = "lastfmplus" - TITLE = "Last.fm.Plus" - PARENT = "plugins" - - options = [ - IntOption("setting", "lastfm_max_minor_tags", 4), - IntOption("setting", "lastfm_max_group_tags", 1), - IntOption("setting", "lastfm_max_mood_tags", 4), - IntOption("setting", "lastfm_max_occasion_tags", 4), - IntOption("setting", "lastfm_max_category_tags", 4), - BoolOption("setting", "lastfm_use_country_tag", True), - BoolOption("setting", "lastfm_use_city_tag", True), - BoolOption("setting", "lastfm_use_decade_tag", True), - BoolOption("setting", "lastfm_use_year_tag", True), - TextOption("setting", "lastfm_join_tags_sign", "; "), - BoolOption("setting", "lastfm_app_major2minor_tag", True), - BoolOption("setting", "lastfm_use_track_tags", True), - IntOption("setting", "lastfm_min_tracktag_weight", 5), - IntOption("setting", "lastfm_max_tracktag_drop", 90), - BoolOption("setting", "lastfm_artist_tag_us_no", False), - BoolOption("setting", "lastfm_artist_tag_us_ex", True), - BoolOption("setting", "lastfm_artist_tag_us_yes", False), - IntOption("setting", "lastfm_artist_tags_weight", 95), - IntOption("setting", "lastfm_min_artisttag_weight", 10), - IntOption("setting", "lastfm_max_artisttag_drop", 80), - TextOption("setting", "lastfm_genre_major", ",".join(GENRE_FILTER["major"]).lower()), - TextOption("setting", "lastfm_genre_minor", ",".join(GENRE_FILTER["minor"]).lower()), - TextOption("setting", "lastfm_genre_decade",", ".join(GENRE_FILTER["decade"]).lower()), - TextOption("setting", "lastfm_genre_year",", ".join(GENRE_FILTER["year"]).lower()), - TextOption("setting", "lastfm_genre_occasion",", ".join(GENRE_FILTER["occasion"]).lower()), - TextOption("setting", "lastfm_genre_category",", ".join(GENRE_FILTER["category"]).lower()), - TextOption("setting", "lastfm_genre_country",", ".join(GENRE_FILTER["country"]).lower()), - TextOption("setting", "lastfm_genre_city",", ".join(GENRE_FILTER["city"]).lower()), - TextOption("setting", "lastfm_genre_mood", ",".join(GENRE_FILTER["mood"]).lower()), - TextOption("setting", "lastfm_genre_translations", "\n".join(["%s,%s" % (k,v) for k, v in GENRE_FILTER["translate"].items()]).lower()) - ] - - def __init__(self, parent=None): - super(LastfmOptionsPage, self).__init__(parent) - self.ui = Ui_LastfmOptionsPage() - self.ui.setupUi(self) - # TODO Not yet implemented properly - # self.connect(self.ui.check_translation_list, QtCore.SIGNAL("clicked()"), self.check_translations) - self.connect(self.ui.check_word_lists, - QtCore.SIGNAL("clicked()"), self.check_words) - self.connect(self.ui.load_default_lists, - QtCore.SIGNAL("clicked()"), self.load_defaults) - self.connect(self.ui.filter_report, - QtCore.SIGNAL("clicked()"), self.create_report) - - # function to check all translations and make sure a corresponding word - # exists in word lists, notify in message translations pointing nowhere. - def check_translations(self): - cfg = self.config.setting - translations = (cfg["lastfm_genre_translations"].replace("\n", "|")) - tr2 = list(item for item in translations.split('|')) - wordlists = (cfg["lastfm_genre_major"] + cfg["lastfm_genre_minor"] + cfg["lastfm_genre_country"] + cfg["lastfm_genre_occasion"] - + cfg["lastfm_genre_mood"] + cfg["lastfm_genre_decade"] + cfg["lastfm_genre_year"] + cfg["lastfm_genre_category"]) - # TODO need to check to see if translations are in wordlists - QtGui.QMessageBox.information( - self, self.tr("QMessageBox.showInformation()"), ",".join(tr2)) - - # function to check that word lists contain no duplicate entries, notify - # in message duplicates and which lists they appear in - def check_words(self): - cfg = self.config.setting - # Create a set for each option cfg option - - word_sets = { - "Major": set(str(self.ui.genre_major.text()).split(",")), - "Minor": set(str(self.ui.genre_minor.text()).split(",")), - "Countries": set(str(self.ui.genre_country.text()).split(",")), - "Cities": set(str(self.ui.genre_city.text()).split(",")), - "Moods": set(str(self.ui.genre_mood.text()).split(",")), - "Occasions": set(str(self.ui.genre_occasion.text()).split(",")), - "Decades": set(str(self.ui.genre_decade.text()).split(",")), - "Years": set(str(self.ui.genre_year.text()).split(",")), - "Categories": set(str(self.ui.genre_category.text()).split(",")) - } - - text = [] - duplicates = {} - - for name, words in word_sets.iteritems(): - for word in words: - word = word.strip().title() - duplicates.setdefault(word, []).append(name) - - for word, names in duplicates.iteritems(): - if len(names) > 1: - names = "%s and %s" % (", ".join(names[:-1]), names.pop()) - text.append('"%s" in %s lists.' % (word, names)) - - if not text: - text = "No issues found." - else: - text = "\n\n".join(text) - - # Display results in information box - QtGui.QMessageBox.information(self, self.tr("QMessageBox.showInformation()"), text) - - # load/reload defaults - def load_defaults(self): - self.ui.genre_major.setText(", ".join(GENRE_FILTER["major"])) - self.ui.genre_minor.setText(", ".join(GENRE_FILTER["minor"])) - self.ui.genre_decade.setText(", ".join(GENRE_FILTER["decade"])) - self.ui.genre_country.setText(", ".join(GENRE_FILTER["country"])) - self.ui.genre_city.setText(", ".join(GENRE_FILTER["city"])) - self.ui.genre_year.setText(", ".join(GENRE_FILTER["year"])) - self.ui.genre_occasion.setText(", ".join(GENRE_FILTER["occasion"])) - self.ui.genre_category.setText(", ".join(GENRE_FILTER["category"])) - self.ui.genre_mood.setText(", ".join(GENRE_FILTER["mood"])) - self.ui.genre_translations.setText("00s, 2000s\n10s, 1910s\n1920's, 1920s\n1930's, 1930s\n1940's, 1940s\n1950's, 1950s\n1960's, 1960s\n1970's, 1970s\n1980's, 1980s\n1990's, 1990s\n2-tone, 2 tone\n20's, 1920s\n2000's, 2000s\n2000s, 2000s\n20s, 1920s\n20th century classical, classical\n30's, 1930s\n30s, 1930s\n3rd wave ska revival, ska\n40's, 1940s\n40s, 1940s\n50's, 1950s\n50s, 1950s\n60's, 1960s\n60s, 1960s\n70's, 1970s\n70s, 1970s\n80's, 1980s\n80s, 1980s\n90's, 1990s\n90s, 1990s\na capella, a cappella\nabstract-hip-hop, hip-hop\nacapella, a cappella\nacid-rock, acid rock\nafrica, african\naggresive, angry\naggressive, angry\nalone, lonely\nalready-dead, deceased\nalt rock, alternative rock\nalt-country, alternative country\nalternative punk, punk\nalternative dance, dance\nalternative hip-hop, hip-hop\nalternative pop-rock, pop rock\nalternative punk, punk\nalternative rap, rap\nambient-techno, ambient\namericain, american\namericana, american\nanimal-songs, animal songs\nanimals, animal songs\nanti-war, protest\narena rock, rock\natmospheric-drum-and-bass, drum and bass\nau, australian\naussie hip hop, aussie hip-hop\naussie hiphop, aussie hip-hop\naussie rock, australian\naussie, australian\naussie-rock, rock\naustralia, australian\naustralian aboriginal, world\naustralian country, country\naustralian hip hop, aussie hip-hop\naustralian hip-hop, aussie hip-hop\naustralian rap, aussie hip-hop\naustralian rock, rock\naustralian-music, australian\naustralianica, australian\naustralicana, australian\naustria, austrian\navantgarde, avant-garde\nbakersfield-sound, bakersfield\nbaroque pop, baroque\nbeach music, beach\nbeat, beats\nbelgian music, belgian\nbelgian-music, belgian\nbelgium, belgian\nbhangra, indian\nbig beat, beats\nbigbeat, beats\nbittersweet, cynical\nblack metal, doom metal\nblue, sad\nblues guitar, blues\nblues-rock, blues rock\nbluesrock, blues rock\nbollywood, indian\nboogie, boogie woogie\nboogiewoogieflu, boogie woogie\nbrazil, brazilian\nbreakbeats, breakbeat\nbreaks artists, breakbeat\nbrit, british\nbrit-pop, brit pop\nbrit-rock, brit rock\nbritish blues, blues\nbritish punk, punk\nbritish rap, rap\nbritish rock, brit rock\nbritish-folk, folk\nbritpop, brit pop\nbritrock, brit rock\nbroken beat, breakbeat\nbrutal-death-metal, doom metal\nbubblegum, bubblegum pop\nbuddha bar, chillout\ncalming, relaxed\ncanada, canadian\ncha-cha, cha cha\ncha-cha-cha, cha cha\nchicago blues, blues\nchildren, kids\nchildrens music, kids\nchildrens, kids\nchill out, chillout\nchill-out, chillout\nchilled, chill\nchillhouse, chill\nchillin, hanging out\nchristian, gospel\nchina, chinese\nclasica, classical\nclassic blues, blues\nclassic jazz, jazz\nclassic metal, metal\nclassic pop, pop\nclassic punk, punk\nclassic roots reggae, roots reggae\nclassic soul, soul\nclassic-hip-hop, hip-hop\nclassical crossover, classical\nclassical music, classical\nclassics, classic tunes\nclassique, classical\nclub-dance, dance\nclub-house, house\nclub-music, club\ncollegiate acappella, a cappella\ncomedy rock, humour\ncomedy, humour\ncomposer, composers\nconscious reggae, reggae\ncontemporary classical, classical\ncontemporary gospel, gospel\ncontemporary jazz, jazz\ncontemporary reggae, reggae\ncool-covers, covers\ncountry folk, country\ncountry soul, country\ncountry-divas, country\ncountry-female, country\ncountry-legends, country\ncountry-pop, country pop\ncountry-rock, country rock\ncover, covers\ncover-song, covers\ncover-songs, covers\ncowboy, country\ncowhat-fav, country\ncowhat-hero, country\ncuba, cuban\ncyberpunk, punk\nd'n'b, drum and bass\ndance party, party\ndance-punk, punk\ndance-rock, rock\ndancefloor, dance\ndancehall-reggae, dancehall\ndancing, dance\ndark-psy, psytrance\ndark-psytrance, psytrance\ndarkpsy, dark ambient\ndeath metal, doom metal\ndeathcore, thrash metal\ndeep house, house\ndeep-soul, soul\ndeepsoul, soul\ndepressing, depressed\ndepressive, depressed \ndeutsch, german\ndisco-funk, disco\ndisco-house, disco\ndiva, divas\ndj mix, dj\ndnb, drum and bass\ndope, drugs\ndownbeat, downtempo\ndream dance, trance\ndream trance, trance\ndrill 'n' bass, drum and bass\ndrill and bass, drum and bass\ndrill n bass, drum and bass\ndrill-n-bass, drum and bass\ndrillandbass, drum and bass\ndrinking songs, drinking\ndriving-music, driving\ndrum 'n' bass, drum and bass\ndrum n bass, drum and bass\ndrum'n'bass, drum and bass\ndrum, drums\ndrum-n-bass, drum and bass\ndrumandbass, drum and bass\ndub-u, dub\ndub-u-dub, dub\ndub-wise, dub\nduet, duets\nduo, duets\ndutch artists, dutch\ndutch rock, rock\ndutch-bands, dutch\ndutch-sound, dutch\nearly reggae, reggae\neasy, easy listening\negypt, egyptian\neighties, 1980s\nelectro dub, electro\nelectro funk, electro\nelectro house, house\nelectro rock, electro\nelectro-pop, electro\nelectroclash, electro\nelectrofunk, electro\nelectrohouse, house\nelectronic, electronica\nelectronic-rock, rock\nelectronicadance, dance\nelectropop, electro pop\nelectropunk, punk\nelegant, stylish\nelektro, electro\nelevator, elevator music\nemotive, emotional\nenergy, energetic\nengland, british\nenglish, british\nenraged, angry\nepic-trance, trance\nethnic fusion, ethnic\neuro-dance, eurodance\neuro-pop, europop\neuro-trance, trance\neurotrance, trance\neurovision, eurodance\nexperimental-rock, experimental\nfair dinkum australian mate, australian\nfeel good music, feel good\nfeelgood, feel good\nfemale artists, female\nfemale country, country\nfemale fronted, female\nfemale singers, female\nfemale vocalist, female vocalists\nfemale-vocal, female vocalists\nfemale-vocals, female vocalists\nfemale-voices, female vocalists\nfield recording, field recordings\nfilm, film score\nfilm-score, film score\nfingerstyle guitar, fingerstyle\nfinland, finnish\nfinnish-metal, metal\nflamenco rumba, rumba\nfolk-jazz, folk jazz\nfolk-pop, folk pop\nfolk-rock, folk rock\nfolkrock, folk rock\nfrancais, french\nfrance, french\nfreestyle, electronica\nfull on, energetic\nfull-on, energetic\nfull-on-psychedelic-trance, psytrance\nfull-on-trance, trance\nfullon, intense \nfuneral, death\nfunky breaks, breaks\nfunky house, house\nfunny, humorous\ngabber, hardcore\ngeneral pop, pop\ngeneral rock, rock\ngentle, smooth\ngermany, german\ngirl-band, girl group\ngirl-group, girl group\ngirl-groups, girl group\ngirl-power, girl group\ngirls, girl group\nglam metal, glam rock\nglam, glam rock\ngloomy, depressed\ngoa classic, goa trance\ngoa, goa trance\ngoa-psy-trance, psytrance\ngoatrance, trance\ngolden oldies, oldies\ngoth rock, gothic rock\ngoth, gothic\ngothic doom metal, gothic metal\ngreat-lyricists, great lyrics\ngreat-lyrics, great lyrics\ngrime, dubstep\ngregorian chant, gregorian\ngrock 'n' roll, rock and roll\ngroovin, groovy\ngrunge rock, grunge\nguitar god, guitar\nguitar gods, guitar\nguitar hero, guitar\nguitar rock, rock\nguitar-solo, guitar solo\nguitar-virtuoso, guitarist\nhair metal, glam rock\nhanging-out, hanging out\nhappiness, happy\nhappy thoughts, happy\nhard dance, dance\nhard house, house\nhard-trance, trance\nhardcore-techno, techno\nhawaii, hawaiian\nheartbreak, heartache\nheavy rock, hard rock\nhilarious, humorous\nhip hop, hip-hop\nhip-hop and rap, hip-hop\nhip-hoprap, hip-hop\nhiphop, hip-hop\nhippie, stoner rock\nhope, hopeful\nhorrorcore, thrash metal\nhorrorpunk, horror punk\nhumor, humour\nindia, indian\nindie electronic, electronica\nindietronica, electronica\ninspirational, inspiring\ninstrumental pop, instrumental \niran, iranian\nireland, irish\nisrael, israeli\nitaly, italian\njam band, jam\njamaica, jamaican\njamaican ska, ska\njamaician, jamaican\njamaican-artists, jamaican\njammer, jam\njazz blues, jazz\njazz funk, jazz\njazz hop, jazz\njazz piano, jazz\njpop, j-pop\njrock, j-rock\njazz rock, jazz\njazzy, jazz\njump blues, blues\nkiwi, new zealand\nlaid back, easy listening\nlatin rock, latin\nlatino, latin\nle rap france, french rap\nlegend, legends\nlegendary, legends\nlekker ska, ska\nlions-reggae-dancehall, dancehall\nlistless, irritated\nlively, energetic\nlove metal, metal\nlove song, romantic\nlove-songs, lovesongs\nlovely, beautiful\nmade-in-usa, american\nmakes me happy, happy\nmale country, country\nmale groups, male\nmale rock, male\nmale solo artists, male\nmale vocalist, male vocalists\nmale-vocal, male vocalists\nmale-vocals, male vocalists\nmarijuana, drugs\nmelancholic days, melancholy\nmelodic death metal, doom metal\nmelodic hardcore, hardcore\nmelodic metal, metal\nmelodic metalcore, metal\nmelodic punk, punk\nmelodic trance, trance\nmetalcore, thrash metal\nmetro downtempo, downtempo\nmetro reggae, reggae\nmiddle east, middle eastern\nminimal techno, techno\nmood, moody\nmorning, wake up\nmoses reggae, reggae\nmovie, soundtracks\nmovie-score, soundtracks\nmovie-score-composers, composers\nmovie-soundtrack, soundtracks\nmusical, musicals\nmusical-theatre, musicals\nneder rock, rock \nnederland, dutch\nnederlands, dutch\nnederlandse-muziek, dutch\nnederlandstalig, dutch\nnederpop, pop\nnederrock, rock\nnederska, ska\nnedertop, dutch\nneo prog, progressive\nneo progressive rock, progressive rock\nneo progressive, progressive\nneo psychedelia, psychedelic\nneo soul, soul\nnerd rock, rock\nnetherlands, dutch\nneurofunk, funk\nnew rave, rave\nnew school breaks, breaks \nnew school hardcore, hardcore\nnew traditionalist country, traditional country\nnice elevator music, elevator music\nnight, late night\nnight-music, late night\nnoise pop, pop\nnoise rock, rock\nnorway, norwegian\nnostalgic, nostalgia\nnu breaks, breaks\nnu jazz, jazz\nnu skool breaks, breaks \nnu-metal, nu metal\nnumber-songs, number songs\nnumbers, number songs\nnumetal, metal\nnz, new zealand\nold country, country\nold school hardcore, hardcore \nold school hip-hop, hip-hop\nold school reggae, reggae\nold school soul, soul\nold-favorites, oldie\nold-skool, old school\nold-timey, oldie\noldschool, old school\none hit wonder, one hit wonders\noptimistic, positive\noutlaw country, country\noz hip hop, aussie hip-hop\noz rock, rock\noz, australian\nozzie, australian\npancaribbean, caribbean\nparodies, parody\nparty-groovin, party\nparty-music, party\nparty-time, party\npiano rock, piano\npolitical punk, punk\npolitical rap, rap\npool party, party\npop country, country pop\npop music, pop\npop rap, rap\npop-rap, rap\npop-rock, pop rock\npop-soul, pop soul\npoprock, pop rock\nportugal, portuguese\npositive-vibrations, positive\npost grunge, grunge\npost hardcore, hardcore\npost-grunge, grunge\npost-hardcore, hardcore\npost-punk, post punk\npost-rock, post rock\npostrock, post rock\npower ballad, ballad\npower ballads, ballad\npower metal, metal\nprog rock, progressive rock\nprogressive breaks, breaks\nprogressive house, house\nprogressive metal, nu metal\nprogressive psytrance, psytrance \nprogressive trance, psytrance\nproto-punk, punk\npsy, psytrance\npsy-trance, psytrance\npsybient, ambient\npsych folk, psychedelic folk\npsych, psytrance\npsychadelic, psychedelic\npsychedelia, psychedelic\npsychedelic pop, psychedelic\npsychedelic trance, psytrance\npsychill, psytrance\npsycho, insane\npsytrance artists, psytrance\npub rock, rock \npunk blues, punk\npunk caberet, punk\npunk favorites, punk \npunk pop, punk\npunk revival, punk\npunkabilly, punk\npunkrock, punk rock\nqueer, quirky\nquiet, relaxed\nr and b, r&b\nr'n'b, r&b\nr-n-b, r&b\nraggae, reggae\nrap and hip-hop, rap\nrap hip-hop, rap\nrap rock, rap\nrapcore, rap metal\nrasta, rastafarian\nrastafari, rastafarian\nreal hip-hop, hip-hop\nreegae, reggae\nreggae and dub, reggae\nreggae broeder, reggae\nreggae dub ska, reggae\nreggae roots, roots reggae\nreggae-pop, reggae pop\nreggea, reggae\nrelax, relaxed\nrelaxing, relaxed\nrhythm and blues, r&b\nrnb, r&b\nroad-trip, driving\nrock ballad, ballad\nrock ballads, ballad\nrock n roll, rock and roll\nrock pop, pop rock\nrock roll, rock and roll\nrock'n'roll, rock and roll\nrock-n-roll, rock and roll\nrocknroll, rock and roll\nrockpop, pop rock\nromance, romantic\nromantic-tension, romantic\nroots and culture, roots\nroots rock, rock\nrootsreggae, roots reggae \nrussian alternative, russian\nsad-songs, sad\nsample, samples\nsaturday night, party\nsax, saxophone\nscotland, scottish\nseden, swedish\nsensual, passionate\nsing along, sing-alongs\nsing alongs, sing-alongs\nsing-along, sing-alongs\nsinger-songwriters, singer-songwriter\nsingersongwriter, singer-songwriter\nsixties, 1960s\nska revival, ska \nska-punk, ska punk\nskacore, ska\nskate punk, punk\nskinhead reggae, reggae\nsleepy, sleep\nslow jams, slow jam\nsmooth soul, soul\nsoft, smooth\nsolo country acts, country\nsolo instrumental, solo instrumentals\nsoothing, smooth\nsoulful drum and bass, drum and bass\nsoundtrack, soundtracks\nsouth africa, african\nsouth african, african\nsouthern rap, rap\nsouthern soul, soul\nspain, spanish\nspeed metal, metal\nspeed, drugs\nspirituals, spiritual\nspliff, drugs\nstoner, stoner rock\nstreet punk, punk\nsuicide, death\nsuicide, suicidal\nsummertime, summer\nsun-is-shining, sunny\nsunshine pop, pop\nsuper pop, pop\nsurf, surf rock\nswamp blues, swamp rock\nswamp, swamp rock\nsweden, swedish\nswedish metal, metal\nsymphonic power metal, symphonic metal\nsynthpop, synth pop\ntexas blues, blues\ntexas country, country\nthird wave ska revival, ska\nthird wave ska, ska\ntraditional-ska, ska\ntrancytune, trance\ntranquility, peaceful\ntribal house, tribal\ntribal rock, tribal\ntrip hop, trip-hop\ntriphop, trip-hop\ntwo tone, 2 tone\ntwo-tone, 2 tone\nuk hip-hop, hip-hop\nuk, british\nunited kingdom, british\nunited states, american\nuntimely-death, deceased\nuplifting trance, trance\nus, american\nusa, american\nvocal house, house\nvocal jazz, jazz vocal\nvocal pop, pop\nvocal, vocals\nwales, welsh\nweed, drugs\nwest-coast, westcoast\nworld music, world\nxmas, christmas\n") - - - def import_newlist(self): - fileName = QtGui.QFileDialog.getOpenFileName(self, - self.tr("QFileDialog.getOpenFileName()"), - self.ui.fileName.text(), - self.tr("All Files (*);;Text Files (*.txt)")) - if not fileName.isEmpty(): - self.ui.fileName.setText(fileName) - columns = [] - lists = {} - with open(fileName) as f: - for line in f: - data = line.rstrip('\r\n').split(",") - if not columns: # first line - columns = tuple(data) - for column in columns: - lists[column] = [] - else: # data lines - for column, value in zip(columns, data): - if value: - lists[column].append(value) - - self.ui.genre_major.setText(', '.join(lists['Major'])) - self.ui.genre_minor.setText(', '.join(lists['Minor'])) - self.ui.genre_country.setText(', '.join(lists['Country'])) - self.ui.genre_city.setText(', '.join(lists['City'])) - self.ui.genre_decade.setText(', '.join(lists['Decade'])) - self.ui.genre_mood.setText(', '.join(lists['Mood'])) - self.ui.genre_occasion.setText(', '.join(lists['Occasion'])) - - # Function to create simple report window. Could do a count of values in - # each section and the amount of translations. Total tags being scanned - # for. - def create_report(self): - cfg = self.config.setting - options = [ - ('lastfm_genre_major', 'Major Genre Terms'), - ('lastfm_genre_minor', 'Minor Genre Terms'), - ('lastfm_genre_country', 'Country Terms'), - ('lastfm_genre_city', 'City Terms'), - ('lastfm_genre_mood', 'Mood Terms'), - ('lastfm_genre_occasion', 'Occasions Terms'), - ('lastfm_genre_decade', 'Decade Terms'), - ('lastfm_genre_year', 'Year Terms'), - ('lastfm_genre_category', 'Category Terms'), - ('lastfm_genre_translations', 'Translation Terms'), - ] - text = [] - for name, label in options: - nterms = cfg[name].count(',') + 1 - if nterms: - text.append(" • %d %s" % (nterms, label)) - if not text: - text = "No terms found" - else: - text = "You have a total of:
" + "
".join(text) + "" - # Display results in information box - QtGui.QMessageBox.information(self, self.tr("QMessageBox.showInformation()"), text) - - def load(self): - # general - cfg = self.config.setting - self.ui.max_minor_tags.setValue(cfg["lastfm_max_minor_tags"]) - self.ui.max_group_tags.setValue(cfg["lastfm_max_group_tags"]) - self.ui.max_mood_tags.setValue(cfg["lastfm_max_mood_tags"]) - self.ui.max_occasion_tags.setValue(cfg["lastfm_max_occasion_tags"]) - self.ui.max_category_tags.setValue(cfg["lastfm_max_category_tags"]) - self.ui.use_country_tag.setChecked(cfg["lastfm_use_country_tag"]) - self.ui.use_city_tag.setChecked(cfg["lastfm_use_city_tag"]) - self.ui.use_decade_tag.setChecked(cfg["lastfm_use_decade_tag"]) - self.ui.use_year_tag.setChecked(cfg["lastfm_use_year_tag"]) - self.ui.join_tags_sign.setText(cfg["lastfm_join_tags_sign"]) - self.ui.app_major2minor_tag.setChecked(cfg["lastfm_app_major2minor_tag"]) - self.ui.use_track_tags.setChecked(cfg["lastfm_use_track_tags"]) - self.ui.min_tracktag_weight.setValue(cfg["lastfm_min_tracktag_weight"]) - self.ui.max_tracktag_drop.setValue(cfg["lastfm_max_tracktag_drop"]) - self.ui.artist_tag_us_no.setChecked(cfg["lastfm_artist_tag_us_no"]) - self.ui.artist_tag_us_ex.setChecked(cfg["lastfm_artist_tag_us_ex"]) - self.ui.artist_tag_us_yes.setChecked(cfg["lastfm_artist_tag_us_yes"]) - self.ui.artist_tags_weight.setValue(cfg["lastfm_artist_tags_weight"]) - self.ui.min_artisttag_weight.setValue(cfg["lastfm_min_artisttag_weight"]) - self.ui.max_artisttag_drop.setValue(cfg["lastfm_max_artisttag_drop"]) - self.ui.genre_major.setText( cfg["lastfm_genre_major"].replace(",", ", ") ) - self.ui.genre_minor.setText( cfg["lastfm_genre_minor"].replace(",", ", ") ) - self.ui.genre_decade.setText( cfg["lastfm_genre_decade"].replace(",", ", ") ) - self.ui.genre_country.setText(cfg["lastfm_genre_country"].replace(",", ", ") ) - self.ui.genre_city.setText(cfg["lastfm_genre_city"].replace(",", ", ") ) - self.ui.genre_year.setText( cfg["lastfm_genre_year"].replace(",", ", ") ) - self.ui.genre_occasion.setText(cfg["lastfm_genre_occasion"].replace(",", ", ") ) - self.ui.genre_category.setText(cfg["lastfm_genre_category"].replace(",", ", ") ) - self.ui.genre_year.setText(cfg["lastfm_genre_year"].replace(",", ", ") ) - self.ui.genre_mood.setText( cfg["lastfm_genre_mood"].replace(",", ", ") ) - self.ui.genre_translations.setText(cfg["lastfm_genre_translations"].replace(",", ", ") ) - - def save(self): - self.config.setting["lastfm_max_minor_tags"] = self.ui.max_minor_tags.value() - self.config.setting["lastfm_max_group_tags"] = self.ui.max_group_tags.value() - self.config.setting["lastfm_max_mood_tags"] = self.ui.max_mood_tags.value() - self.config.setting["lastfm_max_occasion_tags"] = self.ui.max_occasion_tags.value() - self.config.setting["lastfm_max_category_tags"] = self.ui.max_category_tags.value() - self.config.setting["lastfm_use_country_tag"] = self.ui.use_country_tag.isChecked() - self.config.setting["lastfm_use_city_tag"] = self.ui.use_city_tag.isChecked() - self.config.setting["lastfm_use_decade_tag"] = self.ui.use_decade_tag.isChecked() - self.config.setting["lastfm_use_year_tag"] = self.ui.use_year_tag.isChecked() - self.config.setting["lastfm_join_tags_sign"] = self.ui.join_tags_sign.text() - self.config.setting["lastfm_app_major2minor_tag"] = self.ui.app_major2minor_tag.isChecked() - self.config.setting["lastfm_use_track_tags"] = self.ui.use_track_tags.isChecked() - self.config.setting["lastfm_min_tracktag_weight"] = self.ui.min_tracktag_weight.value() - self.config.setting["lastfm_max_tracktag_drop"] = self.ui.max_tracktag_drop.value() - self.config.setting["lastfm_artist_tag_us_no"] = self.ui.artist_tag_us_no.isChecked() - self.config.setting["lastfm_artist_tag_us_ex"] = self.ui.artist_tag_us_ex.isChecked() - self.config.setting["lastfm_artist_tag_us_yes"] = self.ui.artist_tag_us_yes.isChecked() - self.config.setting["lastfm_artist_tags_weight"] = self.ui.artist_tags_weight.value() - self.config.setting["lastfm_min_artisttag_weight"] = self.ui.min_artisttag_weight.value() - self.config.setting["lastfm_max_artisttag_drop"] = self.ui.max_artisttag_drop.value() - - # parse littlebit the text-inputs - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_major.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_major"] = ",".join(tmp1) - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_minor.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_minor"] = ",".join(tmp1) - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_decade.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_decade"] = ",".join(tmp1) - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_year.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_year"] = ",".join(tmp1) - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_country.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_country"] = ",".join(tmp1) - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_city.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_city"] = ",".join(tmp1) - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_occasion.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_occasion"] = ",".join(tmp1) - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_category.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_category"] = ",".join(tmp1) - tmp0 = {} - tmp1 = [tmp0.setdefault(i.strip(), i.strip()) - for i in unicode(self.ui.genre_mood.text()).lower().split(",") if i not in tmp0] - tmp1.sort() - self.config.setting["lastfm_genre_mood"] = ",".join(tmp1) - - trans = {} - tmp0 = unicode( - self.ui.genre_translations.toPlainText()).lower().split("\n") - for tmp1 in tmp0: - tmp2 = tmp1.split(',') - if len(tmp2) == 2: - tmp2[0] = tmp2[0].strip() - tmp2[1] = tmp2[1].strip() - if len(tmp2[0]) < 1 or len(tmp2[1]) < 1: - continue - if tmp2[0] in trans and trans[tmp2[0]] != tmp2[1]: - del trans[tmp2[0]] - elif not tmp2[0] in trans: - trans[tmp2[0]] = tmp2[1] - - tmp3 = trans.items() - tmp3.sort() - self.config.setting["lastfm_genre_translations"] = "\n".join(["%s,%s" % (k,v) for k, v in tmp3]) - GENRE_FILTER["_loaded_"] = False - - -register_track_metadata_processor(process_track) -register_options_page(LastfmOptionsPage) diff --git a/plugins/lastfmplus/ui_options_lastfm.py b/plugins/lastfmplus/ui_options_lastfm.py deleted file mode 100644 index f2743a54..00000000 --- a/plugins/lastfmplus/ui_options_lastfm.py +++ /dev/null @@ -1,748 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'options_lastfmplus.ui' -# -# Created: Thu Jul 23 10:55:17 2009 -# by: PyQt4 UI code generator 4.4.4 -# -# WARNING! All changes made in this file will be lost! - -from PyQt4 import QtCore, QtGui - - -class Ui_LastfmOptionsPage(object): - - def setupUi(self, LastfmOptionsPage): - LastfmOptionsPage.setObjectName("LastfmOptionsPage") - LastfmOptionsPage.resize(414, 493) - self.horizontalLayout = QtGui.QHBoxLayout(LastfmOptionsPage) - self.horizontalLayout.setObjectName("horizontalLayout") - self.tabWidget = QtGui.QTabWidget(LastfmOptionsPage) - self.tabWidget.setMinimumSize(QtCore.QSize(330, 475)) - self.tabWidget.setElideMode(QtCore.Qt.ElideNone) - self.tabWidget.setUsesScrollButtons(False) - self.tabWidget.setObjectName("tabWidget") - self.tab_4 = QtGui.QWidget() - self.tab_4.setObjectName("tab_4") - self.gridLayout_3 = QtGui.QGridLayout(self.tab_4) - self.gridLayout_3.setObjectName("gridLayout_3") - self.groupBox_5 = QtGui.QGroupBox(self.tab_4) - self.groupBox_5.setMinimumSize(QtCore.QSize(0, 0)) - self.groupBox_5.setBaseSize(QtCore.QSize(0, 0)) - self.groupBox_5.setObjectName("groupBox_5") - self.gridLayout_2 = QtGui.QGridLayout(self.groupBox_5) - self.gridLayout_2.setObjectName("gridLayout_2") - self.label_10 = QtGui.QLabel(self.groupBox_5) - self.label_10.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_10.setObjectName("label_10") - self.gridLayout_2.addWidget(self.label_10, 0, 0, 1, 1) - self.max_group_tags = QtGui.QSpinBox(self.groupBox_5) - self.max_group_tags.setObjectName("max_group_tags") - self.gridLayout_2.addWidget(self.max_group_tags, 0, 1, 1, 1) - spacerItem = QtGui.QSpacerItem(20, 95, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) - self.gridLayout_2.addItem(spacerItem, 0, 2, 4, 1) - self.label_12 = QtGui.QLabel(self.groupBox_5) - self.label_12.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_12.setObjectName("label_12") - self.gridLayout_2.addWidget(self.label_12, 0, 3, 1, 1) - self.max_mood_tags = QtGui.QSpinBox(self.groupBox_5) - self.max_mood_tags.setObjectName("max_mood_tags") - self.gridLayout_2.addWidget(self.max_mood_tags, 0, 4, 1, 1) - self.label_11 = QtGui.QLabel(self.groupBox_5) - self.label_11.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_11.setObjectName("label_11") - self.gridLayout_2.addWidget(self.label_11, 1, 0, 1, 1) - self.max_minor_tags = QtGui.QSpinBox(self.groupBox_5) - self.max_minor_tags.setObjectName("max_minor_tags") - self.gridLayout_2.addWidget(self.max_minor_tags, 1, 1, 1, 1) - self.label_14 = QtGui.QLabel(self.groupBox_5) - self.label_14.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_14.setObjectName("label_14") - self.gridLayout_2.addWidget(self.label_14, 1, 3, 1, 1) - self.max_occasion_tags = QtGui.QSpinBox(self.groupBox_5) - self.max_occasion_tags.setObjectName("max_occasion_tags") - self.gridLayout_2.addWidget(self.max_occasion_tags, 1, 4, 1, 1) - self.label_15 = QtGui.QLabel(self.groupBox_5) - self.label_15.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_15.setObjectName("label_15") - self.gridLayout_2.addWidget(self.label_15, 2, 3, 1, 1) - self.max_category_tags = QtGui.QSpinBox(self.groupBox_5) - self.max_category_tags.setObjectName("max_category_tags") - self.gridLayout_2.addWidget(self.max_category_tags, 2, 4, 1, 1) - self.app_major2minor_tag = QtGui.QCheckBox(self.groupBox_5) - self.app_major2minor_tag.setObjectName("app_major2minor_tag") - self.gridLayout_2.addWidget(self.app_major2minor_tag, 3, 0, 1, 2) - self.label_26 = QtGui.QLabel(self.groupBox_5) - self.label_26.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_26.setObjectName("label_26") - self.gridLayout_2.addWidget(self.label_26, 3, 3, 1, 1) - self.join_tags_sign = QtGui.QLineEdit(self.groupBox_5) - self.join_tags_sign.setObjectName("join_tags_sign") - self.gridLayout_2.addWidget(self.join_tags_sign, 3, 4, 1, 1) - self.gridLayout_3.addWidget(self.groupBox_5, 0, 0, 1, 1) - self.groupBox_4 = QtGui.QGroupBox(self.tab_4) - self.groupBox_4.setObjectName("groupBox_4") - self.gridLayout_6 = QtGui.QGridLayout(self.groupBox_4) - self.gridLayout_6.setObjectName("gridLayout_6") - self.use_country_tag = QtGui.QCheckBox(self.groupBox_4) - self.use_country_tag.setObjectName("use_country_tag") - self.gridLayout_6.addWidget(self.use_country_tag, 0, 0, 1, 1) - self.use_city_tag = QtGui.QCheckBox(self.groupBox_4) - self.use_city_tag.setTristate(False) - self.use_city_tag.setObjectName("use_city_tag") - self.gridLayout_6.addWidget(self.use_city_tag, 1, 0, 1, 1) - self.use_year_tag = QtGui.QCheckBox(self.groupBox_4) - self.use_year_tag.setObjectName("use_year_tag") - self.gridLayout_6.addWidget(self.use_year_tag, 0, 1, 1, 1) - self.use_decade_tag = QtGui.QCheckBox(self.groupBox_4) - self.use_decade_tag.setObjectName("use_decade_tag") - self.gridLayout_6.addWidget(self.use_decade_tag, 1, 1, 1, 1) - self.gridLayout_3.addWidget(self.groupBox_4, 1, 0, 1, 1) - self.groupBox_9 = QtGui.QGroupBox(self.tab_4) - self.groupBox_9.setObjectName("groupBox_9") - self.gridLayout_4 = QtGui.QGridLayout(self.groupBox_9) - self.gridLayout_4.setObjectName("gridLayout_4") - self.use_track_tags = QtGui.QCheckBox(self.groupBox_9) - self.use_track_tags.setChecked(False) - self.use_track_tags.setObjectName("use_track_tags") - self.gridLayout_4.addWidget(self.use_track_tags, 0, 0, 1, 1) - self.label_19 = QtGui.QLabel(self.groupBox_9) - self.label_19.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_19.setObjectName("label_19") - self.gridLayout_4.addWidget(self.label_19, 0, 2, 1, 1) - self.min_tracktag_weight = QtGui.QSpinBox(self.groupBox_9) - self.min_tracktag_weight.setObjectName("min_tracktag_weight") - self.gridLayout_4.addWidget(self.min_tracktag_weight, 0, 3, 1, 1) - self.label_20 = QtGui.QLabel(self.groupBox_9) - self.label_20.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_20.setObjectName("label_20") - self.gridLayout_4.addWidget(self.label_20, 1, 2, 1, 1) - self.max_tracktag_drop = QtGui.QSpinBox(self.groupBox_9) - self.max_tracktag_drop.setObjectName("max_tracktag_drop") - self.gridLayout_4.addWidget(self.max_tracktag_drop, 1, 3, 1, 1) - spacerItem1 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) - self.gridLayout_4.addItem(spacerItem1, 0, 1, 2, 1) - self.gridLayout_3.addWidget(self.groupBox_9, 2, 0, 1, 1) - self.groupBox_10 = QtGui.QGroupBox(self.tab_4) - self.groupBox_10.setObjectName("groupBox_10") - self.gridLayout_5 = QtGui.QGridLayout(self.groupBox_10) - self.gridLayout_5.setObjectName("gridLayout_5") - spacerItem2 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) - self.gridLayout_5.addItem(spacerItem2, 0, 1, 3, 1) - self.artist_tag_us_no = QtGui.QRadioButton(self.groupBox_10) - self.artist_tag_us_no.setObjectName("artist_tag_us_no") - self.gridLayout_5.addWidget(self.artist_tag_us_no, 0, 0, 1, 1) - self.label_21 = QtGui.QLabel(self.groupBox_10) - self.label_21.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_21.setObjectName("label_21") - self.gridLayout_5.addWidget(self.label_21, 0, 2, 1, 1) - self.artist_tags_weight = QtGui.QSpinBox(self.groupBox_10) - self.artist_tags_weight.setObjectName("artist_tags_weight") - self.gridLayout_5.addWidget(self.artist_tags_weight, 0, 3, 1, 1) - self.artist_tag_us_ex = QtGui.QRadioButton(self.groupBox_10) - self.artist_tag_us_ex.setObjectName("artist_tag_us_ex") - self.gridLayout_5.addWidget(self.artist_tag_us_ex, 1, 0, 1, 1) - self.label_22 = QtGui.QLabel(self.groupBox_10) - self.label_22.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_22.setObjectName("label_22") - self.gridLayout_5.addWidget(self.label_22, 1, 2, 1, 1) - self.min_artisttag_weight = QtGui.QSpinBox(self.groupBox_10) - self.min_artisttag_weight.setObjectName("min_artisttag_weight") - self.gridLayout_5.addWidget(self.min_artisttag_weight, 1, 3, 1, 1) - self.artist_tag_us_yes = QtGui.QRadioButton(self.groupBox_10) - self.artist_tag_us_yes.setObjectName("artist_tag_us_yes") - self.gridLayout_5.addWidget(self.artist_tag_us_yes, 2, 0, 1, 1) - self.label_23 = QtGui.QLabel(self.groupBox_10) - self.label_23.setLayoutDirection(QtCore.Qt.RightToLeft) - self.label_23.setObjectName("label_23") - self.gridLayout_5.addWidget(self.label_23, 2, 2, 1, 1) - self.max_artisttag_drop = QtGui.QSpinBox(self.groupBox_10) - self.max_artisttag_drop.setObjectName("max_artisttag_drop") - self.gridLayout_5.addWidget(self.max_artisttag_drop, 2, 3, 1, 1) - self.gridLayout_3.addWidget(self.groupBox_10, 3, 0, 1, 1) - self.tabWidget.addTab(self.tab_4, "") - self.tab_3 = QtGui.QWidget() - self.tab_3.setObjectName("tab_3") - self.gridLayout_7 = QtGui.QGridLayout(self.tab_3) - self.gridLayout_7.setObjectName("gridLayout_7") - self.groupBox_3 = QtGui.QGroupBox(self.tab_3) - self.groupBox_3.setObjectName("groupBox_3") - self.gridLayout = QtGui.QGridLayout(self.groupBox_3) - self.gridLayout.setObjectName("gridLayout") - self.label = QtGui.QLabel(self.groupBox_3) - self.label.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label.setObjectName("label") - self.gridLayout.addWidget(self.label, 0, 0, 1, 1) - self.genre_major = QtGui.QLineEdit(self.groupBox_3) - self.genre_major.setObjectName("genre_major") - self.gridLayout.addWidget(self.genre_major, 0, 1, 1, 1) - self.label_2 = QtGui.QLabel(self.groupBox_3) - self.label_2.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label_2.setObjectName("label_2") - self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1) - self.genre_minor = QtGui.QLineEdit(self.groupBox_3) - self.genre_minor.setObjectName("genre_minor") - self.gridLayout.addWidget(self.genre_minor, 1, 1, 1, 1) - self.label_3 = QtGui.QLabel(self.groupBox_3) - self.label_3.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label_3.setObjectName("label_3") - self.gridLayout.addWidget(self.label_3, 2, 0, 1, 1) - self.genre_mood = QtGui.QLineEdit(self.groupBox_3) - self.genre_mood.setObjectName("genre_mood") - self.gridLayout.addWidget(self.genre_mood, 2, 1, 1, 1) - self.label_5 = QtGui.QLabel(self.groupBox_3) - self.label_5.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label_5.setObjectName("label_5") - self.gridLayout.addWidget(self.label_5, 3, 0, 1, 1) - self.genre_year = QtGui.QLineEdit(self.groupBox_3) - self.genre_year.setObjectName("genre_year") - self.gridLayout.addWidget(self.genre_year, 3, 1, 1, 1) - self.label_4 = QtGui.QLabel(self.groupBox_3) - self.label_4.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label_4.setObjectName("label_4") - self.gridLayout.addWidget(self.label_4, 4, 0, 1, 1) - self.genre_occasion = QtGui.QLineEdit(self.groupBox_3) - self.genre_occasion.setObjectName("genre_occasion") - self.gridLayout.addWidget(self.genre_occasion, 4, 1, 1, 1) - self.label_6 = QtGui.QLabel(self.groupBox_3) - self.label_6.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label_6.setObjectName("label_6") - self.gridLayout.addWidget(self.label_6, 5, 0, 1, 1) - self.genre_decade = QtGui.QLineEdit(self.groupBox_3) - self.genre_decade.setObjectName("genre_decade") - self.gridLayout.addWidget(self.genre_decade, 5, 1, 1, 1) - self.label_7 = QtGui.QLabel(self.groupBox_3) - self.label_7.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label_7.setObjectName("label_7") - self.gridLayout.addWidget(self.label_7, 6, 0, 1, 1) - self.genre_country = QtGui.QLineEdit(self.groupBox_3) - self.genre_country.setObjectName("genre_country") - self.gridLayout.addWidget(self.genre_country, 6, 1, 1, 1) - self.label_9 = QtGui.QLabel(self.groupBox_3) - self.label_9.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label_9.setObjectName("label_9") - self.gridLayout.addWidget(self.label_9, 7, 0, 1, 1) - self.genre_city = QtGui.QLineEdit(self.groupBox_3) - self.genre_city.setObjectName("genre_city") - self.gridLayout.addWidget(self.genre_city, 7, 1, 1, 1) - self.label_8 = QtGui.QLabel(self.groupBox_3) - self.label_8.setLayoutDirection(QtCore.Qt.LeftToRight) - self.label_8.setObjectName("label_8") - self.gridLayout.addWidget(self.label_8, 8, 0, 1, 1) - self.genre_category = QtGui.QLineEdit(self.groupBox_3) - self.genre_category.setObjectName("genre_category") - self.gridLayout.addWidget(self.genre_category, 8, 1, 1, 1) - self.gridLayout_7.addWidget(self.groupBox_3, 0, 0, 1, 2) - self.groupBox_17 = QtGui.QGroupBox(self.tab_3) - self.groupBox_17.setObjectName("groupBox_17") - self.horizontalLayout_4 = QtGui.QHBoxLayout(self.groupBox_17) - self.horizontalLayout_4.setObjectName("horizontalLayout_4") - self.genre_translations = QtGui.QTextEdit(self.groupBox_17) - self.genre_translations.setObjectName("genre_translations") - self.horizontalLayout_4.addWidget(self.genre_translations) - self.gridLayout_7.addWidget(self.groupBox_17, 1, 0, 1, 1) - self.groupBox_2 = QtGui.QGroupBox(self.tab_3) - self.groupBox_2.setObjectName("groupBox_2") - self.verticalLayout = QtGui.QVBoxLayout(self.groupBox_2) - self.verticalLayout.setObjectName("verticalLayout") - self.filter_report = QtGui.QPushButton(self.groupBox_2) - self.filter_report.setObjectName("filter_report") - self.verticalLayout.addWidget(self.filter_report) - self.check_word_lists = QtGui.QPushButton(self.groupBox_2) - self.check_word_lists.setObjectName("check_word_lists") - self.verticalLayout.addWidget(self.check_word_lists) - self.check_translation_list = QtGui.QPushButton(self.groupBox_2) - self.check_translation_list.setEnabled(False) - self.check_translation_list.setObjectName("check_translation_list") - self.verticalLayout.addWidget(self.check_translation_list) - spacerItem3 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) - self.verticalLayout.addItem(spacerItem3) - self.load_default_lists = QtGui.QPushButton(self.groupBox_2) - font = QtGui.QFont() - font.setWeight(75) - font.setBold(True) - self.load_default_lists.setFont(font) - self.load_default_lists.setObjectName("load_default_lists") - self.verticalLayout.addWidget(self.load_default_lists) - self.gridLayout_7.addWidget(self.groupBox_2, 1, 1, 1, 1) - self.tabWidget.addTab(self.tab_3, "") - self.horizontalLayout.addWidget(self.tabWidget) - - self.retranslateUi(LastfmOptionsPage) - self.tabWidget.setCurrentIndex(0) - QtCore.QMetaObject.connectSlotsByName(LastfmOptionsPage) - LastfmOptionsPage.setTabOrder(self.max_group_tags, self.max_minor_tags) - LastfmOptionsPage.setTabOrder(self.max_minor_tags, self.use_track_tags) - LastfmOptionsPage.setTabOrder(self.use_track_tags, self.min_tracktag_weight) - LastfmOptionsPage.setTabOrder(self.min_tracktag_weight, self.max_tracktag_drop) - LastfmOptionsPage.setTabOrder(self.max_tracktag_drop, self.artist_tag_us_no) - LastfmOptionsPage.setTabOrder(self.artist_tag_us_no, self.artist_tag_us_ex) - LastfmOptionsPage.setTabOrder(self.artist_tag_us_ex, self.artist_tag_us_yes) - LastfmOptionsPage.setTabOrder(self.artist_tag_us_yes, self.artist_tags_weight) - LastfmOptionsPage.setTabOrder(self.artist_tags_weight, self.min_artisttag_weight) - LastfmOptionsPage.setTabOrder(self.min_artisttag_weight, self.max_artisttag_drop) - LastfmOptionsPage.setTabOrder(self.max_artisttag_drop, self.genre_major) - LastfmOptionsPage.setTabOrder(self.genre_major, self.genre_minor) - LastfmOptionsPage.setTabOrder(self.genre_minor, self.genre_mood) - LastfmOptionsPage.setTabOrder(self.genre_mood, self.genre_year) - LastfmOptionsPage.setTabOrder(self.genre_year, self.genre_occasion) - LastfmOptionsPage.setTabOrder(self.genre_occasion, self.genre_decade) - LastfmOptionsPage.setTabOrder(self.genre_decade, self.genre_country) - LastfmOptionsPage.setTabOrder(self.genre_country, self.genre_category) - LastfmOptionsPage.setTabOrder(self.genre_category, self.genre_translations) - LastfmOptionsPage.setTabOrder(self.genre_translations, self.filter_report) - LastfmOptionsPage.setTabOrder(self.filter_report, self.check_word_lists) - LastfmOptionsPage.setTabOrder(self.check_word_lists, self.check_translation_list) - LastfmOptionsPage.setTabOrder(self.check_translation_list, self.load_default_lists) - - def retranslateUi(self, LastfmOptionsPage): - LastfmOptionsPage.setWindowTitle(QtGui.QApplication.translate("LastfmOptionsPage", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.tabWidget.setWindowTitle(QtGui.QApplication.translate("LastfmOptionsPage", "LastfmOptionsPage", None, QtGui.QApplication.UnicodeUTF8)) - self.groupBox_5.setTitle(QtGui.QApplication.translate("LastfmOptionsPage", "Max Tags Written 0=Disabled 1=One Tag 2+= Multiple Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.label_10.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Major Tags - Group", None, QtGui.QApplication.UnicodeUTF8)) - self.max_group_tags.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Max Grouping (Major Genres) Tags

\n" -"

Tag Name: %GROUPING%

\n" -"

Top-level genres ex: Classical, Rock, Soundtracks

\n" -"

Written to Grouping tag. Can also be appended to

\n" -"

Genre tag if \'Append Major\' box (below) is checked.

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_12.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Max Mood Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.max_mood_tags.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Max Mood Tags ID3v2.4+ Only!

\n" -"

Tag: %MOOD%

\n" -"

How a track \'feels\'. ex: Happy, Introspective, Drunk

\n" -"

Note: The TMOO frame is only standard in ID3v2.4 tags.

\n" -"

For all other tags, Moods will be saved as a Comment.

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_11.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Minor Tags - Genre", None, QtGui.QApplication.UnicodeUTF8)) - self.max_minor_tags.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Max Genre Tags

\n" -"

Tag: %GENRE%

\n" -"

These are specific, detailed genres. ex: Baroque, Classic Rock, Delta Blues

\n" -"

Set this to 1 if using this tag for file naming,

\n" -"

or if your player doesn\'t support multi-value tags

\n" -"

\n" -"

Consider setting this to 3+ if you use Genre

\n" -"

for searching in your music library.

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_14.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Max Occasion Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.max_occasion_tags.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Max Occasion Tags Nonstandard!

\n" -"

Tag: %Comment:Songs-db_Occasion%

\n" -"

Good situations to play a track, ex: Driving, Love, Party

\n" -"

Set to 2+ to increase this tag\'s usefulness.

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_15.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Max Category Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.max_category_tags.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Max Category Tags Nonstandard!

\n" -"

Tag: %Comment:Songs-db_Custom2%

\n" -"

Another Top-level grouping tag.

\n" -"

Contains tags like: Female Vocalists, Singer-Songwriter

", None, QtGui.QApplication.UnicodeUTF8)) - self.app_major2minor_tag.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Append Major to Minor Tags

\n" -"

This will prepend any Grouping tags

\n" -"

onto the Genre tag at tagging time. The effect is

\n" -"

that the Grouping Tag which is also the Major Genre

\n" -"

Becomes the First Genre in the List of Minor Genres

", None, QtGui.QApplication.UnicodeUTF8)) - self.app_major2minor_tag.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Prepend Major to Minor Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.label_26.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Join Tags With", None, QtGui.QApplication.UnicodeUTF8)) - self.join_tags_sign.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

The Separator to use for Multi-Value tags

\n" -"

You may want to add a trailing space to

\n" -"

help with readability.

\n" -"

To use Separate Tags rather than a single

\n" -"

multi-value tag leave this field blank ie. no space at all.

", None, QtGui.QApplication.UnicodeUTF8)) - self.join_tags_sign.setText(QtGui.QApplication.translate("LastfmOptionsPage", ";", None, QtGui.QApplication.UnicodeUTF8)) - self.groupBox_4.setTitle(QtGui.QApplication.translate("LastfmOptionsPage", "Enable (Selected) or Disable (Not Selected) other Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.use_country_tag.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Country Nonstandard!

\n" -"

Tag: %Comment:Songs-db_Custom2%

\n" -"

The country the artist or track is most

\n" -"

associated with. Will retrieve results using the Country

\n" -"

tag list on Tag Filter List Page

", None, QtGui.QApplication.UnicodeUTF8)) - self.use_country_tag.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Country", None, QtGui.QApplication.UnicodeUTF8)) - self.use_city_tag.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Country Nonstandard!

\n" -"

Tag: %Comment:Songs-db_Custom2%

\n" -"

The city or region the artist or track is most

\n" -"

associated with. If Enabled will use the most popular

\n" -"

tag in the City list on Tag Filter Options page. If

\n" -"

Country option has been selected as well the City tag

\n" -"

will be displayed second in the tag list.

", None, QtGui.QApplication.UnicodeUTF8)) - self.use_city_tag.setText(QtGui.QApplication.translate("LastfmOptionsPage", "City", None, QtGui.QApplication.UnicodeUTF8)) - self.use_year_tag.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Original Year Nonstandard!

\n" -"

Tag: %ID3:TDOR% or %ID3:TORY%

\n" -"

The year the song was created or most popular in. Quite often

\n" -"

this is the correct original release year of the track. The tag

\n" -"

written to is determined by the settings you have selected

\n" -"

in Picard Tag options. Ie. if ID3v2.3 is selected the original year tag

\n" -"

will be ID3:TORY rather than the default of ID3:TDOR

", None, QtGui.QApplication.UnicodeUTF8)) - self.use_year_tag.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Original Year", None, QtGui.QApplication.UnicodeUTF8)) - self.use_decade_tag.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Decade Nonstandard!

\n" -"

Tag: %Comment:Songs-db_Custom1%

\n" -"

The decade the song was created, ex: 1970s

\n" -"

This is based on the last fm tags first, if none found then

\n" -"

the originalyear tag, and then the release date of the album.

", None, QtGui.QApplication.UnicodeUTF8)) - self.use_decade_tag.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Decade", None, QtGui.QApplication.UnicodeUTF8)) - self.groupBox_9.setTitle(QtGui.QApplication.translate("LastfmOptionsPage", "Track Based Tags: Tags based on Track Title and Artist", None, QtGui.QApplication.UnicodeUTF8)) - self.use_track_tags.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Check this to use Track-based tags.

\n" -"

These are tags relevant to the song

", None, QtGui.QApplication.UnicodeUTF8)) - self.use_track_tags.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Use Track Based Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.label_19.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Minimum Tag Weight", None, QtGui.QApplication.UnicodeUTF8)) - self.min_tracktag_weight.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

The minimum weight track-based tag

\n" -"

to use, in terms of popularity

", None, QtGui.QApplication.UnicodeUTF8)) - self.min_tracktag_weight.setSuffix(QtGui.QApplication.translate("LastfmOptionsPage", " %", None, QtGui.QApplication.UnicodeUTF8)) - self.label_20.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Maximum Inter-Tag Drop", None, QtGui.QApplication.UnicodeUTF8)) - self.max_tracktag_drop.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

The maximum allowed drop in relevance

\n" -"

for the tag to still be a match

", None, QtGui.QApplication.UnicodeUTF8)) - self.max_tracktag_drop.setSuffix(QtGui.QApplication.translate("LastfmOptionsPage", " %", None, QtGui.QApplication.UnicodeUTF8)) - self.groupBox_10.setTitle(QtGui.QApplication.translate("LastfmOptionsPage", "Artist Based Tags: Based on the Artist, not the Track Title.", None, QtGui.QApplication.UnicodeUTF8)) - self.artist_tag_us_no.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Select this to Never use Artist based tags

\n" -"

Be sure you have Use Track-Based Tags

\n" -"

checked, though.

", None, QtGui.QApplication.UnicodeUTF8)) - self.artist_tag_us_no.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Don\'t use Artist Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.label_21.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Artist-Tags Weight", None, QtGui.QApplication.UnicodeUTF8)) - self.artist_tags_weight.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

How strongly Artist-based tags

\n" -"

are considered for inclusion

", None, QtGui.QApplication.UnicodeUTF8)) - self.artist_tags_weight.setSuffix(QtGui.QApplication.translate("LastfmOptionsPage", " %", None, QtGui.QApplication.UnicodeUTF8)) - self.artist_tag_us_ex.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Enabling this uses Artist-based tags only

\n" -"

if there aren\'t enough Track-based tags.

\n" -"

Default: Enabled

", None, QtGui.QApplication.UnicodeUTF8)) - self.artist_tag_us_ex.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Extend Track-Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.label_22.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Minimum Tag Weight", None, QtGui.QApplication.UnicodeUTF8)) - self.min_artisttag_weight.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

The minimum weight Artist-based tag

\n" -"

to use, in terms of popularity

", None, QtGui.QApplication.UnicodeUTF8)) - self.min_artisttag_weight.setSuffix(QtGui.QApplication.translate("LastfmOptionsPage", " %", None, QtGui.QApplication.UnicodeUTF8)) - self.artist_tag_us_yes.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Enable this to Always use Artist-based tags

", None, QtGui.QApplication.UnicodeUTF8)) - self.artist_tag_us_yes.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Use Artist-Tags", None, QtGui.QApplication.UnicodeUTF8)) - self.label_23.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Maximum Inter-Tag Drop", None, QtGui.QApplication.UnicodeUTF8)) - self.max_artisttag_drop.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

The maximum allowed drop in relevance

\n" -"

for the tag to still be a match

", None, QtGui.QApplication.UnicodeUTF8)) - self.max_artisttag_drop.setSuffix(QtGui.QApplication.translate("LastfmOptionsPage", " %", None, QtGui.QApplication.UnicodeUTF8)) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_4), QtGui.QApplication.translate("LastfmOptionsPage", "General Options", None, QtGui.QApplication.UnicodeUTF8)) - self.groupBox_3.setTitle(QtGui.QApplication.translate("LastfmOptionsPage", "Tag Lists", None, QtGui.QApplication.UnicodeUTF8)) - self.label.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Grouping", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_major.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Major Genres

\n" -"

Top-level genres ex: Classical, Rock, Soundtracks

\n" -"

Written to Grouping tag. Can also be appended to

\n" -"

Genre tag if enabled in General Options.

\n" -"

\n" -"

Tag Name: %GROUPING% ID3 Frame: TIT1

\n" -"

Supported: Mp3, Ogg, Flac, Wma, AAC

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar YES CONTENT GROUP

\n" -"

iTunes YES GROUPING

\n" -"

MediaMonkey YES GROUPING

\n" -"

MP3Tag YES CONTENTGROUP

\n" -"

Winamp UNK ---

\n" -"

WMP YES MUSIC CATEGORY DESCRIPTION

\n" -"

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_2.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Genres", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_minor.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Minor Genres

\n" -"

More specific genres. ex: Baroque, Classic Rock, Delta Blues

\n" -"

Written to Genre tag.

\n" -"

\n" -"

Tag Name: %GENRE% ID3 Frame: TCON

\n" -"

Supported: Mp3, Ogg, Flac, Wma, AAC, Ape, Wav

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar YES GENRE

\n" -"

iTunes YES GENRE

\n" -"

MediaMonkey YES GENRE

\n" -"

MP3Tag YES GENRE

\n" -"

Winamp YES GENRE

\n" -"

WMP YES GENRE

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_3.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Mood", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_mood.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Mood ID3v2.4+ Only!

\n" -"

How a track \'feels\'. ex: Happy, Introspective, Drunk

\n" -"

Note: The TMOO frame is only standard in ID3v2.4 tags.

\n" -"

For all other tags, Moods are saved as a Comment.

\n" -"

\n" -"

Tag Name: %MOOD% ID3 Frame: TMOO

\n" -"

Supported: Mp3, Ogg, Flac, Wma, AAC

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar NO ---

\n" -"

iTunes UNK ---

\n" -"

MediaMonkey YES MOOD

\n" -"

MP3Tag YES MOOD

\n" -"

Winamp UNK ---

\n" -"

WMP YES WM/MOOD

\n" -"

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_5.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Years", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_year.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Original Year

\n" -"

The year the track was first recorded.

\n" -"

Note: This tag is often missing or wrong.

\n" -"

If Blank, the album release date is used.

\n" -"

\n" -"

Tag Name: %ORIGINALDATE%

\n" -"

ID3 Frame: V2.3: TORY v2.4: TDOR

\n" -"

Supported: Mp3, Ogg, Flac, Wma, AAC

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar YES ORIGINAL RELEASE DATE

\n" -"

iTunes UNK ---

\n" -"

MediaMonkey YES ORIGINAL DATE

\n" -"

MP3Tag YES ORIGYEAR

\n" -"

Winamp UNK ---

\n" -"

WMP UNK ---

\n" -"

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_4.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Occasion", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_occasion.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Occasions Nonstandard!

\n" -"

Good times to play the track, ex: Driving, Love, Party

\n" -"

Written to the Comment tag. Has very limited support.

\n" -"

\n" -"

Tag Name: %Comment:Songs-db_Occasion%

\n" -"

ID3 Frame: COMM:Songs-db_Occasion

\n" -"

Supported: Mp3, Ogg, Flac

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar NO ---

\n" -"

iTunes NO ---

\n" -"

MediaMonkey YES Custom tag

\n" -"

MP3Tag YES Comment:Songs-db_Occasion

\n" -"

Winamp NO ---

\n" -"

WMP UNK ---

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_6.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Decades", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_decade.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Decade Nonstandard!

\n" -"

The decade the song was created. Based on

\n" -"

originalyear, so will frequently be wrong.

\n" -"

Unless your app can map Comment subvalues

\n" -"

this tag will show as part of any existing comment.

\n" -"

\n" -"

Tag Name: %Comment:Songs-db_Custom1%

\n" -"

ID3 Frame: COMM:Songs-db_Custom1

\n" -"

Supported: Mp3, Ogg, Flac

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar NO ---

\n" -"

iTunes NO ---

\n" -"

MediaMonkey YES Custom tag

\n" -"

MP3Tag YES Comment:Songs-db_Custom1

\n" -"

Winamp NO ---

\n" -"

WMP NO ---

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_7.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Country", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_country.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Country Nonstandard!

\n" -"

Artist country/location info, ex: America, New York, NYC

\n" -"

Allowing more tags will usually give more detailed info.

\n" -"

Unless your app maps Comment subvalues

\n" -"

this tag will show as part of any existing comment.

\n" -"

\n" -"

Tag Name: %Comment:Songs-db_Custom3%

\n" -"

ID3 Frame: COMM:Songs-db_Custom3

\n" -"

Supported: Mp3, Ogg, Flac

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar NO ---

\n" -"

iTunes NO ---

\n" -"

MediaMonkey YES Custom tag

\n" -"

MP3Tag YES Comment:Songs-db_Custom3

\n" -"

Winamp NO ---

\n" -"

WMP NO ---

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_9.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Cities", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_city.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Country Nonstandard!

\n" -"

Artist country/location info, ex: America, New York, NYC

\n" -"

Allowing more tags will usually give more detailed info.

\n" -"

Unless your app maps Comment subvalues

\n" -"

this tag will show as part of any existing comment.

\n" -"

\n" -"

Tag Name: %Comment:Songs-db_Custom3%

\n" -"

ID3 Frame: COMM:Songs-db_Custom3

\n" -"

Supported: Mp3, Ogg, Flac

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar NO ---

\n" -"

iTunes NO ---

\n" -"

MediaMonkey YES Custom tag

\n" -"

MP3Tag YES Comment:Songs-db_Custom3

\n" -"

Winamp NO ---

\n" -"

WMP NO ---

", None, QtGui.QApplication.UnicodeUTF8)) - self.label_8.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Category", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_category.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Category Nonstandard!

\n" -"

Another Top-level grouping tag.

\n" -"

Returns terms like Female Vocalists, Singer-Songwriter

\n" -"

Unless your app can map Comment subvalues

\n" -"

this tag will show as part of any existing comment.

\n" -"

\n" -"

Tag Name: %Comment:Songs-db_Custom2%

\n" -"

ID3 Frame: COMM:Songs-db_Custom2

\n" -"

Supported: Mp3, Ogg, Flac

\n" -"

\n" -"

Compatibility Tag Mapping

\n" -"

______________________________________________________

\n" -"

Foobar NO ---

\n" -"

iTunes NO ---

\n" -"

MediaMonkey YES Custom tag

\n" -"

MP3Tag YES Comment:Songs-db_Custom2

\n" -"

Winamp NO ---

\n" -"

WMP NO ---

", None, QtGui.QApplication.UnicodeUTF8)) - self.groupBox_17.setTitle(QtGui.QApplication.translate("LastfmOptionsPage", "Tag Translations", None, QtGui.QApplication.UnicodeUTF8)) - self.genre_translations.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Tag Translations

\n" -"

This list lets you change how matches from the

\n" -"

tag lists are actually written into your tags.

\n" -"

\n" -"

Typical Uses:

\n" -"

- Standardize spellings, ex: rock-n-roll , rock and roll , rock \'n roll --> rock & roll

\n" -"

- Clean formatting, ex: lovesongs --> love songs

\n" -"

- Condense related tags, ex: heavy metal, hair metal, power metal --> metal

\n" -"

\n" -"

Usage: Old Name, New Name

\n" -"

One Rule per line:

\n" -"

\n" -"

Death Metal, Metal

\n" -"

Sunshine-Pop, Pop

\n" -"

Super-awesome-musics, Nice

", None, QtGui.QApplication.UnicodeUTF8)) - self.groupBox_2.setTitle(QtGui.QApplication.translate("LastfmOptionsPage", "Tools", None, QtGui.QApplication.UnicodeUTF8)) - self.filter_report.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Filter Report

\n" -"

Tells you how many tags and

\n" -"

translations you have in each list.

\n" -"

", None, QtGui.QApplication.UnicodeUTF8)) - self.filter_report.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Filter Report", None, QtGui.QApplication.UnicodeUTF8)) - self.check_word_lists.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Check Tag Lists

\n" -"

Each tag may appear only once across all lists.

\n" -"

This scans all the lists for duplicated tags

\n" -"

so you can easily remove them.

\n" -"

Run this whenever you add tags to a list!

\n" -"

", None, QtGui.QApplication.UnicodeUTF8)) - self.check_word_lists.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Check Tag Lists", None, QtGui.QApplication.UnicodeUTF8)) - self.check_translation_list.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

Not implemented yet.

", None, QtGui.QApplication.UnicodeUTF8)) - self.check_translation_list.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Translations", None, QtGui.QApplication.UnicodeUTF8)) - self.load_default_lists.setToolTip(QtGui.QApplication.translate("LastfmOptionsPage", "\n" -"\n" -"

WARNING!

\n" -"

This will overwrite all current

\n" -"

Tag Lists and Translations!

", None, QtGui.QApplication.UnicodeUTF8)) - self.load_default_lists.setText(QtGui.QApplication.translate("LastfmOptionsPage", "Load Defaults", None, QtGui.QApplication.UnicodeUTF8)) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_3), QtGui.QApplication.translate("LastfmOptionsPage", "Tag Filter Lists", None, QtGui.QApplication.UnicodeUTF8)) - diff --git a/plugins/loadasnat/loadasnat.py b/plugins/loadasnat/loadasnat.py index 1a537006..2a8c7ce2 100644 --- a/plugins/loadasnat/loadasnat.py +++ b/plugins/loadasnat/loadasnat.py @@ -1,50 +1,75 @@ # -*- coding: utf-8 -*- +# +# Copyright (C) 2017, 2019 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. PLUGIN_NAME = "Load as non-album track" PLUGIN_AUTHOR = "Philipp Wolfer" -PLUGIN_DESCRIPTION = "Allows loading selected tracks as non-album tracks. Useful for tagging single tracks where you do not care about the album." -PLUGIN_VERSION = "0.1" -PLUGIN_API_VERSIONS = ["1.4.0", "2.0"] -PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_DESCRIPTION = ("Allows loading selected tracks as non-album tracks. " + "Useful for tagging single tracks where you do not care " + "about the album.") +PLUGIN_VERSION = "0.3" +PLUGIN_API_VERSIONS = ["1.4.0", "2.0", "2.1", "2.2"] +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from picard import log from picard.album import Track -from picard.ui.itemviews import BaseAction, register_track_action +from picard.ui.itemviews import ( + BaseAction, + register_track_action, +) + class LoadAsNat(BaseAction): - NAME = "Load as non-album track..." - - def callback(self, objs): - tracks = [t for t in objs if isinstance(t, Track)] - - if len(tracks) == 0: - return - - for track in tracks: - nat = self.tagger.load_nat(track.metadata['musicbrainz_recordingid']) - for file in track.iterfiles(): - file.move(nat) - file.metadata.delete('albumartist') - file.metadata.delete('albumartistsort') - file.metadata.delete('albumsort') - file.metadata.delete('asin') - file.metadata.delete('barcode') - file.metadata.delete('catalognumber') - file.metadata.delete('discnumber') - file.metadata.delete('discsubtitle') - file.metadata.delete('media') - file.metadata.delete('musicbrainz_albumartistid') - file.metadata.delete('musicbrainz_albumid') - file.metadata.delete('musicbrainz_discid') - file.metadata.delete('musicbrainz_releasegroupid') - file.metadata.delete('releasecountry') - file.metadata.delete('releasestatus') - file.metadata.delete('releasetype') - file.metadata.delete('totaldiscs') - file.metadata.delete('totaltracks') - file.metadata.delete('tracknumber') - log.debug("[LoadAsNat] deleted tags: %r", file.metadata.deleted_tags) + NAME = "Load as non-album track..." + + def callback(self, objs): + tracks = [t for t in objs if isinstance(t, Track)] + + if len(tracks) == 0: + return + + for track in tracks: + nat = self.tagger.load_nat( + track.metadata['musicbrainz_recordingid']) + for file in list(track.linked_files): + file.move(nat) + metadata = file.metadata + metadata.delete('albumartist') + metadata.delete('albumartistsort') + metadata.delete('albumsort') + metadata.delete('asin') + metadata.delete('barcode') + metadata.delete('catalognumber') + metadata.delete('discnumber') + metadata.delete('discsubtitle') + metadata.delete('media') + metadata.delete('musicbrainz_albumartistid') + metadata.delete('musicbrainz_albumid') + metadata.delete('musicbrainz_discid') + metadata.delete('musicbrainz_releasegroupid') + metadata.delete('releasecountry') + metadata.delete('releasestatus') + metadata.delete('releasetype') + metadata.delete('totaldiscs') + metadata.delete('totaltracks') + metadata.delete('tracknumber') + log.debug("[LoadAsNat] deleted tags: %r", metadata.deleted_tags) register_track_action(LoadAsNat()) diff --git a/plugins/musixmatch/__init__.py b/plugins/musixmatch/__init__.py index 738c2298..74247ec1 100644 --- a/plugins/musixmatch/__init__.py +++ b/plugins/musixmatch/__init__.py @@ -1,24 +1,76 @@ PLUGIN_NAME = 'Musixmatch Lyrics' -PLUGIN_AUTHOR = 'm-yn, Sambhav Kothari' +PLUGIN_AUTHOR = 'm-yn, Sambhav Kothari, Philipp Wolfer' PLUGIN_DESCRIPTION = 'Fetch first 30% of lyrics from Musixmatch' -PLUGIN_VERSION = '1.0' +PLUGIN_VERSION = '1.1' PLUGIN_API_VERSIONS = ["2.0"] PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" +from functools import partial +from picard import config, log from picard.metadata import register_track_metadata_processor from picard.ui.options import register_options_page, OptionsPage -from picard.config import TextOption from .ui_options_musixmatch import Ui_MusixmatchOptionsPage +MUSIXMATCH_HOST = 'api.musixmatch.com' +MUSIXMATCH_PORT = 80 + + +def handle_result(album, metadata, data, reply, error): + try: + if error: + log.error(error) + return + message = data.get('message', {}) + header = message.get('header') + if header.get('status_code') != 200: + log.warning('MusixMatch: Server returned no result: %s', data) + return + result = message.get('body', {}).get('lyrics') + if result: + lyrics = result.get('lyrics_body') + if lyrics: + metadata['lyrics:description'] = lyrics + except AttributeError: + log.error('MusixMatch: Error handling server response %s', + data, exc_info=True) + finally: + album._requests -= 1 + album._finalize_loading(error) + + +def process_track(album, metadata, release, track): + apikey = config.setting['musixmatch_api_key'] + if not apikey: + log.warning('MusixMatch: No API key configured') + return + if metadata['language'] == 'zxx': + log.debug('MusixMatch: Track %s has no lyrics, skipping query', + metadata['musicbrainz_recordingid']) + return + queryargs = { + 'apikey': apikey, + 'track_mbid': metadata['musicbrainz_recordingid'] + } + album.tagger.webservice.get( + MUSIXMATCH_HOST, + MUSIXMATCH_PORT, + "/ws/1.1/track.lyrics.get", + partial(handle_result, album, metadata), + parse_response_type='json', + queryargs=queryargs + ) + album._requests += 1 + + class MusixmatchOptionsPage(OptionsPage): NAME = 'musixmatch' TITLE = 'Musixmatch API Key' PARENT = "plugins" options = [ - TextOption("setting", "musixmatch_api_key", "") + config.TextOption("setting", "musixmatch_api_key", "") ] def __init__(self, parent=None): @@ -27,35 +79,11 @@ def __init__(self, parent=None): self.ui.setupUi(self) def load(self): - self.ui.api_key.setText(self.config.setting["musixmatch_api_key"]) + self.ui.api_key.setText(config.setting["musixmatch_api_key"]) def save(self): self.config.setting["musixmatch_api_key"] = self.ui.api_key.text() -register_options_page(MusixmatchOptionsPage) - -import picard.tagger as tagger -import os -try: - os.environ['MUSIXMATCH_API_KEY'] = tagger.config.setting[ - "musixmatch_api_key"] -except: - pass -from .musixmatch import track as TRACK - - -def process_track(album, metadata, release, track): - if('MUSIXMATCH_API_KEY' not in os.environ): - return - try: - t = TRACK.Track(metadata['musicbrainz_trackid'], - musicbrainz=True).lyrics() - if t['instrumental'] == 1: - lyrics = "[Instrumental]" - else: - lyrics = t['lyrics_body'] - metadata['lyrics:description'] = lyrics - except Exception as e: - pass register_track_metadata_processor(process_track) +register_options_page(MusixmatchOptionsPage) diff --git a/plugins/musixmatch/musixmatch/__init__.py b/plugins/musixmatch/musixmatch/__init__.py deleted file mode 100644 index 0c8fd4b4..00000000 --- a/plugins/musixmatch/musixmatch/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -PLUGIN_NAME = 'Musixmatch Lyrics' -PLUGIN_AUTHOR = 'm-yn, Sambhav Kothari' -PLUGIN_DESCRIPTION = 'Fetch first 30% of lyrics from Musixmatch' -PLUGIN_VERSION = '1.0' -PLUGIN_API_VERSIONS = ["2.0"] -PLUGIN_LICENSE = "GPL-2.0" -PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" diff --git a/plugins/musixmatch/musixmatch/track.py b/plugins/musixmatch/musixmatch/track.py deleted file mode 100644 index d2f22da9..00000000 --- a/plugins/musixmatch/musixmatch/track.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -track.py - by Amelie Anglade and Thierry Bertin-Mahieux - amelie.anglade@gmail.com & tb2332@columbia.edu - -Edited by m-yn: no urllib2, no Queue, no bisect - -Class and functions to query MusixMatch regarding a track -(find the track, get lyrics, chart info, ...) - -(c) 2011, A. Anglade and T. Bertin-Mahieux - -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 3 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, see . -""" - -import os -import sys -from . import util - - -class Track(object): - """ - Class to query the musixmatch API tracks - If the class is constructed with a MusixMatch ID (default), - we assume the ID exists. - The constructor can find the track from a musicbrainz ID - or Echo Nest track ID. - Then, one can search for lyrics or charts. - """ - - #track.get in API - def __init__(self, track_id, musicbrainz=False, echonest=False, - trackdata=None): - """ - Create a Track object based on a given ID. - If musicbrainz or echonest is True, search for the song. - Takes a musixmatch ID (if both musicbrainz and echonest are False) - or musicbrainz id or echo nest track id - Raises an exception if the track is not found. - INPUT - track_id - track id (from whatever service) - musicbrainz - set to True if track_id from musicbrainz - echonest - set to True if track_id from The Echo Nest - trackdata - if you already have the information about - the track (after a search), bypass API call - """ - if musicbrainz and echonest: - msg = 'Creating a Track, only musicbrainz OR echonest can be True.' - raise ValueError(msg) - if trackdata is None: - if musicbrainz: - # params = {'musicbrainz_id': track_id} - # amin - params = {'track_mbid': track_id} - elif echonest: - params = {'echonest_track_id': track_id} - else: - params = {'track_id': track_id} - # url call - body = util.call('track.get', params) - trackdata = body['track'] - # save result - for k in list(trackdata.keys()): - self.__setattr__(k, trackdata[k]) - - # track.lyrics.get in the API - def lyrics(self): - """ - Get the lyrics for that track. - RETURN - dictionary containing keys: - - 'lyrics_body' (main data) - - 'lyrics_id' - - 'lyrics_language' - - 'lyrics copyright' - - 'pixel_tracking_url' - - 'script_tracking_url' - """ - body = util.call('track.lyrics.get', {'track_id': self.track_id}) - return body["lyrics"] - - #track.subtitle.get in API - def subtitles(self): - """ - Get subtitles, available for a few songs as of 02/2011 - Returns dictionary. - """ - body = util.call('track.subtitle.get', {'track_id': self.track_id}) - return body["subtitle"] - - # track.lyrics.feedback.post - def feedback(self, feedback): - """ - To leave feedback about lyrics for this track. - PARAMETERS - 'feedback' can be one of: - * wrong_attribution: the lyrics shown are not by the artist that I selected. - * bad_characters: there are strange characters and/or words - that are partially scrambled. - * lines_too_long: the text for each verse is too long! - * wrong_verses: there are some verses missing from the beginning - or at the end. - * wrong_formatting: the text looks horrible, please fix it! - """ - params = {'track_id': self.track_id, 'lyrics_id': self.lyrics_id, - 'feedback': feedback} - body = util.call('track.lyrics.feedback.post', params) - - def __str__(self): - """ pretty printout """ - return 'MusixMatch Track: ' + str(self.__dict__) - - -#track.search in API -def search(**args): - """ - Parameters: - q: a string that will be searched in every data field - (q_track, q_artist, q_lyrics) - q_track: words to be searched among track titles - q_artist: words to be searched among artist names - q_track_artist: words to be searched among track titles or artist names - q_lyrics: words to be searched into the lyrics - page: requested page of results - page_size: desired number of items per result page - f_has_lyrics: exclude tracks without an available lyrics - (automatic if q_lyrics is set) - f_artist_id: filter the results by the artist_id - f_artist_mbid: filter the results by the artist_mbid - quorum_factor: only works together with q and q_track_artist parameter. - Possible values goes from 0.1 to 0.9 - A value of 0.9 means: 'match at least 90 percent of the words'. - """ - # sanity check - valid_params = ('q', 'q_track', 'q_artist', 'q_track_artist', 'q_lyrics', - 'page', 'page_size', 'f_has_lyrics', 'f_artist_id', - 'f_artist_mbid', 'quorum_factor', 'apikey') - for k in list(args.keys()): - if not k in valid_params: - raise util.MusixMatchAPIError(-1, - 'Invalid track search param: ' + str(k)) - # call and gather a list of tracks - track_list = list() - params = dict((k, v) for k, v in list(args.items()) if not v is None) - body = util.call('track.search', params) - track_list_dict = body["track_list"] - for track_dict in track_list_dict: - t = Track(-1, trackdata=track_dict["track"]) - track_list.append(t) - return track_list - - -#track.chart.get in API -def chart(**args): - """ - Parameters: - page: requested page of results - page_size: desired number of items per result page - country: the country code of the desired country chart - f_has_lyrics: exclude tracks without an available lyrics - (automatic if q_lyrics is set) - """ - # sanity check - valid_params = ('page', 'page_size', 'country', 'f_has_lyrics', 'apikey') - for k in list(args.keys()): - if not k in valid_params: - raise util.MusixMatchAPIError(-1, 'Invalid chart param: ' + str(k)) - # do the call and gather track list - track_list = list() - params = dict((k, v) for k, v in list(args.items()) if not v is None) - body = util.call('track.chart.get', params) - track_list_dict = body["track_list"] - for track_dict in track_list_dict: - t = Track(-1, trackdata=track_dict["track"]) - track_list.append(t) - return track_list diff --git a/plugins/musixmatch/musixmatch/util.py b/plugins/musixmatch/musixmatch/util.py deleted file mode 100644 index c3679ac3..00000000 --- a/plugins/musixmatch/musixmatch/util.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -util.py - by Amelie Anglade and Thierry Bertin-Mahieux - amelie.anglade@gmail.com & tb2332@columbia.edu - -Edited by m-yn: no urllib2, no Queue, no bisect - -Set of util functions used by the MusixMatch Python API, -mostly do HMTL calls. - -(c) 2011, A. Anglade and T. Bertin-Mahieux - -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 3 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, see . -""" - -import os -import sys -import time -import copy -import urllib.request, urllib.parse, urllib.error -try: - import json -except ImportError: - import simplejson as json - -# MusixMatch API key, should be an environment variable -MUSIXMATCH_API_KEY = None -if('MUSIXMATCH_API_KEY' in os.environ): - MUSIXMATCH_API_KEY = os.environ['MUSIXMATCH_API_KEY'] - -# details of the website to call -API_HOST = 'api.musixmatch.com' -API_SELECTOR = '/ws/1.1/' - -# cache time length (seconds) -CACHE_TLENGTH = 3600 - - -class TimedCache(): - """ - Class to cach hashable object for a given time length - """ - - def __init__(self, verbose=0): - """ contructor, init main dict and priority queue """ - self.stuff = {} - self.last_cleanup = time.time() - self.verbose = verbose - - def cache(self, query, res): - """ - Cache a query with a given result - Use the occasion to remove one old stuff if needed - """ - # remove old stuff - curr_time = time.time() - if curr_time - self.last_cleanup > CACHE_TLENGTH: - if self.verbose: - print('we cleanup cache') - new_stuff = {} - new_stuff.update([x for x in list(self.stuff.items()) if curr_time - x[1][0] < CACHE_TLENGTH]) - self.stuff = new_stuff - self.last_cleanup = curr_time - # add object to cache (try/except should be useless now) - try: - hashcode = hash(query) - if self.verbose: - print(('cache, hashcode is:', hashcode)) - self.stuff[hashcode] = (time.time(), copy.deepcopy(res)) - except TypeError as e: - print(('Error, stuff not hashable:', e)) - pass - - def query_cache(self, query): - """ - query the cache for a given query - Return None if not there or too old - """ - hashcode = hash(query) - if self.verbose: - print(('query_cache, hashcode is:', hashcode)) - if hashcode in list(self.stuff.keys()): - data = self.stuff[hashcode] - if time.time() - data[0] > CACHE_TLENGTH: - self.stuff.pop(hashcode) - return None - return data[1] - return None - -# instace of the cache -MXMPY_CACHE = TimedCache() - - -# typical API error message -class MusixMatchAPIError(Exception): - """ - Error raised when the status code returned by - the MusixMatch API is not 200 - """ - - def __init__(self, code, message=None): - self.mxm_code = code - if message is None: - message = status_code(code) - self.args = ('MusixMatch API Error %d: %s' % (code, message),) - - -def call(method, params, nocaching=False): - """ - Do the GET call to the MusixMatch API - Paramteres - method - string describing the method, e.g. track.get - params - dictionary of params, e.g. track_id -> 123 - nocaching - set to True to disable caching - """ - for k, v in list(params.items()): - if isinstance(v, str): - params[k] = v.encode('utf-8') - # sanity checks - params['format'] = 'json' - if not 'apikey' in list(params.keys()) or params['apikey'] is None: - params['apikey'] = MUSIXMATCH_API_KEY - if params['apikey'] is None: - raise MusixMatchAPIError(-1, 'EMPTY API KEY, NOT IN YOUR ENVIRONMENT?') - params = urllib.parse.urlencode(params) - # caching - if not nocaching: - cached_res = MXMPY_CACHE.query_cache(method + str(params)) - if not cached_res is None: - return cached_res - # encode the url request, call - url = 'http://%s%s%s?%s' % (API_HOST, API_SELECTOR, method, params) - # print url - f = urllib.request.urlopen(url) - response = f.read() - # decode response into json - response = decode_json(response) - # return body if status is OK - res_checked = check_status(response) - # cache - if not nocaching: - MXMPY_CACHE.cache(method + str(params), res_checked) - # done - return res_checked - - -def decode_json(raw_json): - """ - Transform the json into a python dictionary - or raise a ValueError - """ - try: - response_dict = json.loads(raw_json) - except ValueError: - raise MusixMatchAPIError(-1, "Unknown error.") - return response_dict - - -def check_status(response): - """ - Checks the response in JSON format - Raise an error, or returns the body of the message - RETURN: - body of the message in JSON - except if error was raised - """ - if not 'message' in list(response.keys()): - raise MusixMatchAPIError(-1) - msg = response['message'] - if not 'header' in list(msg.keys()): - raise MusixMatchAPIError(-1) - header = msg['header'] - if not 'status_code' in list(header.keys()): - raise MusixMatchAPIError(-1) - code = header['status_code'] - if code != 200: - raise MusixMatchAPIError(code) - # all good, return body - body = msg['body'] - return body - - -def status_code(value): - """ - Get a value, i.e. error code as a int. - Returns an appropriate message. - """ - if value == 200: - q = "The request was successful." - return q - if value == 400: - q = "The request had bad syntax or was inherently impossible" - q += " to be satisfied." - return q - if value == 401: - q = "Authentication failed, probably because of a bad API key." - return q - if value == 402: - q = "A limit was reached, either you exceeded per hour requests" - q += " limits or your balance is insufficient." - return q - if value == 403: - q = "You are not authorized to perform this operation / the api" - q += " version you're trying to use has been shut down." - return q - if value == 404: - q = "Requested resource was not found." - return q - if value == 405: - q = "Requested method was not found." - return q - # wrong code? - return "Unknown error code: " + str(value) diff --git a/plugins/no_release/no_release.py b/plugins/no_release/no_release.py index 3af0ba8a..929239c8 100644 --- a/plugins/no_release/no_release.py +++ b/plugins/no_release/no_release.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- PLUGIN_NAME = 'No release' -PLUGIN_AUTHOR = 'Johannes Weißl' +PLUGIN_AUTHOR = 'Johannes Weißl, Philipp Wolfer' PLUGIN_DESCRIPTION = '''Do not store specific release information in releases of unknown origin.''' -PLUGIN_VERSION = '0.1' -PLUGIN_API_VERSIONS = ['0.15'] +PLUGIN_VERSION = '0.2' +PLUGIN_API_VERSIONS = ['2.0'] -from PyQt4 import QtCore, QtGui +from PyQt5 import QtCore, QtGui, QtWidgets +from picard import config from picard.album import Album from picard.metadata import register_album_metadata_processor, register_track_metadata_processor from picard.ui.options import register_options_page, OptionsPage @@ -20,43 +21,42 @@ class Ui_NoReleaseOptionsPage(object): def setupUi(self, NoReleaseOptionsPage): NoReleaseOptionsPage.setObjectName('NoReleaseOptionsPage') NoReleaseOptionsPage.resize(394, 300) - self.verticalLayout = QtGui.QVBoxLayout(NoReleaseOptionsPage) + self.verticalLayout = QtWidgets.QVBoxLayout(NoReleaseOptionsPage) self.verticalLayout.setObjectName('verticalLayout') - self.groupBox = QtGui.QGroupBox(NoReleaseOptionsPage) + self.groupBox = QtWidgets.QGroupBox(NoReleaseOptionsPage) self.groupBox.setObjectName('groupBox') - self.vboxlayout = QtGui.QVBoxLayout(self.groupBox) + self.vboxlayout = QtWidgets.QVBoxLayout(self.groupBox) self.vboxlayout.setObjectName('vboxlayout') - self.norelease_enable = QtGui.QCheckBox(self.groupBox) + self.norelease_enable = QtWidgets.QCheckBox(self.groupBox) self.norelease_enable.setObjectName('norelease_enable') self.vboxlayout.addWidget(self.norelease_enable) - self.label = QtGui.QLabel(self.groupBox) + self.label = QtWidgets.QLabel(self.groupBox) self.label.setObjectName('label') self.vboxlayout.addWidget(self.label) - self.horizontalLayout = QtGui.QHBoxLayout() + self.horizontalLayout = QtWidgets.QHBoxLayout() self.horizontalLayout.setObjectName('horizontalLayout') - self.norelease_strip_tags = QtGui.QLineEdit(self.groupBox) + self.norelease_strip_tags = QtWidgets.QLineEdit(self.groupBox) self.norelease_strip_tags.setObjectName('norelease_strip_tags') self.horizontalLayout.addWidget(self.norelease_strip_tags) self.vboxlayout.addLayout(self.horizontalLayout) self.verticalLayout.addWidget(self.groupBox) - spacerItem = QtGui.QSpacerItem(368, 187, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + spacerItem = QtWidgets.QSpacerItem(368, 187, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) self.verticalLayout.addItem(spacerItem) self.retranslateUi(NoReleaseOptionsPage) QtCore.QMetaObject.connectSlotsByName(NoReleaseOptionsPage) def retranslateUi(self, NoReleaseOptionsPage): - self.groupBox.setTitle(QtGui.QApplication.translate('NoReleaseOptionsPage', 'No release', None, QtGui.QApplication.UnicodeUTF8)) - self.norelease_enable.setText(QtGui.QApplication.translate('NoReleaseOptionsPage', _('Enable plugin for all releases by default'), None, QtGui.QApplication.UnicodeUTF8)) - self.label.setText(QtGui.QApplication.translate('NoReleaseOptionsPage', _('Tags to strip (comma-separated)'), None, QtGui.QApplication.UnicodeUTF8)) + self.groupBox.setTitle(QtWidgets.QApplication.translate('NoReleaseOptionsPage', 'No release')) + self.norelease_enable.setText(QtWidgets.QApplication.translate('NoReleaseOptionsPage', _('Enable plugin for all releases by default'))) + self.label.setText(QtWidgets.QApplication.translate('NoReleaseOptionsPage', _('Tags to strip (comma-separated)'))) -def strip_release_specific_metadata(tagger, metadata): - strip_tags = tagger.config.setting['norelease_strip_tags'] +def strip_release_specific_metadata(metadata): + strip_tags = config.setting['norelease_strip_tags'] strip_tags = [tag.strip() for tag in strip_tags.split(',')] for tag in strip_tags: - if tag in metadata: - del metadata[tag] + metadata.delete(tag) class NoReleaseAction(BaseAction): @@ -65,9 +65,9 @@ class NoReleaseAction(BaseAction): def callback(self, objs): for album in objs: if isinstance(album, Album): - strip_release_specific_metadata(self.tagger, album.metadata) + strip_release_specific_metadata(album.metadata) for track in album.tracks: - strip_release_specific_metadata(self.tagger, track.metadata) + strip_release_specific_metadata(track.metadata) for file in track.linked_files: track.update_file_metadata(file) album.update() @@ -89,22 +89,23 @@ def __init__(self, parent=None): self.ui.setupUi(self) def load(self): - self.ui.norelease_strip_tags.setText(self.config.setting['norelease_strip_tags']) - self.ui.norelease_enable.setChecked(self.config.setting['norelease_enable']) + self.ui.norelease_strip_tags.setText(config.setting['norelease_strip_tags']) + self.ui.norelease_enable.setChecked(config.setting['norelease_enable']) def save(self): - self.config.setting['norelease_strip_tags'] = unicode(self.ui.norelease_strip_tags.text()) - self.config.setting['norelease_enable'] = self.ui.norelease_enable.isChecked() + config.setting['norelease_strip_tags'] = str(self.ui.norelease_strip_tags.text()) + config.setting['norelease_enable'] = self.ui.norelease_enable.isChecked() def NoReleaseAlbumProcessor(tagger, metadata, release): - if tagger.config.setting['norelease_enable']: - strip_release_specific_metadata(tagger, metadata) + if config.setting['norelease_enable']: + strip_release_specific_metadata(metadata) def NoReleaseTrackProcessor(tagger, metadata, track, release): - if tagger.config.setting['norelease_enable']: - strip_release_specific_metadata(tagger, metadata) + if config.setting['norelease_enable']: + strip_release_specific_metadata(metadata) + register_album_metadata_processor(NoReleaseAlbumProcessor) register_track_metadata_processor(NoReleaseTrackProcessor) diff --git a/plugins/non_ascii_equivalents/non_ascii_equivalents.py b/plugins/non_ascii_equivalents/non_ascii_equivalents.py index ae002f0a..f40f6fe4 100644 --- a/plugins/non_ascii_equivalents/non_ascii_equivalents.py +++ b/plugins/non_ascii_equivalents/non_ascii_equivalents.py @@ -19,9 +19,9 @@ PLUGIN_NAME = "Non-ASCII Equivalents" PLUGIN_AUTHOR = "Anderson Mesquita " -PLUGIN_VERSION = "0.1" +PLUGIN_VERSION = "0.2" PLUGIN_API_VERSIONS = ["0.9", "0.10", "0.11", "0.15", "2.0"] -PLUGIN_LICENSE = "GPLv3" +PLUGIN_LICENSE = "GPL-3.0-or-later" PLUGIN_LICENSE_URL = "https://gnu.org/licenses/gpl.html" PLUGIN_DESCRIPTION = '''Replaces accented and otherwise non-ASCII characters with a somewhat equivalent version of their ASCII counterparts. This allows old diff --git a/plugins/padded/padded.py b/plugins/padded/padded.py index d6fbb9db..efe2e1b9 100644 --- a/plugins/padded/padded.py +++ b/plugins/padded/padded.py @@ -8,7 +8,7 @@ PLUGIN_VERSION = "1.0" PLUGIN_API_VERSIONS = ["0.15.0", "0.15.1", "0.16.0", "1.0.0", "1.1.0", "1.2.0", "1.3.0", "2.0", ] -PLUGIN_LICENSE = "GPL-2.0 or later" +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from picard.metadata import register_track_metadata_processor diff --git a/plugins/papercdcase/papercdcase.py b/plugins/papercdcase/papercdcase.py index b1d0f992..37e9fcce 100644 --- a/plugins/papercdcase/papercdcase.py +++ b/plugins/papercdcase/papercdcase.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2015 Philipp Wolfer +# Copyright (C) 2015, 2019 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -19,36 +19,39 @@ PLUGIN_NAME = 'Paper CD case' PLUGIN_AUTHOR = 'Philipp Wolfer, Sambhav Kothari' -PLUGIN_DESCRIPTION = 'Create a paper CD case from an album or cluster using http://papercdcase.com' -PLUGIN_VERSION = "1.1" -PLUGIN_API_VERSIONS = ["2.0"] -PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_DESCRIPTION = ('Create a paper CD case from an album or cluster ' + 'using http://papercdcase.com') +PLUGIN_VERSION = "1.2.1" +PLUGIN_API_VERSIONS = ["2.0", "2.1", "2.2"] +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from PyQt5.QtCore import QUrl, QUrlQuery from picard.album import Album from picard.cluster import Cluster -from picard.ui.itemviews import BaseAction, register_album_action, register_cluster_action +from picard.ui.itemviews import ( + BaseAction, + register_album_action, + register_cluster_action, +) from picard.util import webbrowser2 from picard.util import textencoding PAPERCDCASE_URL = 'http://papercdcase.com/advanced.php' - - URLENCODE_ASCII_CHARS = [ord(' '), ord('%'), ord('&'), ord('/'), ord('?')] def urlencode(s): - l = [] + chars = [] for c in s: n = ord(c) if (n < 128 or n > 255) and n not in URLENCODE_ASCII_CHARS: - l.append(c) + chars.append(c) else: - l.append('%' + hex(n)[2:].upper()) - return ''.join(l) + chars.append('%' + hex(n)[2:].upper()) + return ''.join(chars) def build_papercdcase_url(artist, album, tracks): diff --git a/plugins/playlist/playlist.py b/plugins/playlist/playlist.py index d7d9ee72..f48bcdca 100644 --- a/plugins/playlist/playlist.py +++ b/plugins/playlist/playlist.py @@ -16,9 +16,9 @@ PLUGIN_DESCRIPTION = """Generate an Extended M3U playlist (.m3u8 file, UTF8 encoded text). Relative pathnames are used where audio files are in the same directory as the playlist, otherwise absolute (full) pathnames are used.""" -PLUGIN_VERSION = "1.0" +PLUGIN_VERSION = "1.1" PLUGIN_API_VERSIONS = ["2.0"] -PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" import os.path diff --git a/plugins/reorder_sides/reorder_sides.py b/plugins/reorder_sides/reorder_sides.py index 23e487f4..6b96cdda 100644 --- a/plugins/reorder_sides/reorder_sides.py +++ b/plugins/reorder_sides/reorder_sides.py @@ -28,9 +28,9 @@ changers (https://en.wikipedia.org/wiki/Record_changer#Automatic_sequencing) play in the correct order.""" -PLUGIN_VERSION = '1.0' +PLUGIN_VERSION = '1.2' PLUGIN_API_VERSIONS = ['2.0'] -PLUGIN_LICENSE = 'GPL-3.0' +PLUGIN_LICENSE = 'GPL-3.0-or-later' PLUGIN_LICENSE_URL = 'https://www.gnu.org/licenses/gpl-3.0.html' import collections @@ -102,15 +102,15 @@ def get_side_info(release): side_info = collections.OrderedDict() - for medium in release.medium_list[0].medium: + for medium in release['media']: current_side = None - for track in medium.track_list[0].track: - tracknumber = track.children['number'][0].text + for track in medium['tracks']: + tracknumber = track['number'] trackside = tracknumber_to_side(tracknumber) try: - int_tracknumber = int(track.children['position'][0].text) + int_tracknumber = int(track['position']) except ValueError: # Non-integer tracknumber, so give up. return None @@ -137,7 +137,7 @@ def get_side_info(release): try: side_info[current_side] = [ - int(medium.children['position'][0].text), + int(medium['position']), int_tracknumber, int_tracknumber, ] @@ -215,12 +215,12 @@ def reorder_sides(tagger, metadata, *args): side_first_tracknumber = side_info[side][1] side_last_tracknumber = side_info[side][2] - metadata['totaldiscs'] = string_(len(all_sides)) - metadata['discnumber'] = string_(all_sides.index(side) + 1) + metadata['totaldiscs'] = str(len(all_sides)) + metadata['discnumber'] = str(all_sides.index(side) + 1) metadata['totaltracks'] = \ - string_(side_last_tracknumber - side_first_tracknumber + 1) + str(side_last_tracknumber - side_first_tracknumber + 1) metadata['tracknumber'] = \ - string_(int(metadata['tracknumber']) - side_first_tracknumber + 1) + str(int(metadata['tracknumber']) - side_first_tracknumber + 1) register_track_metadata_processor(reorder_sides) diff --git a/plugins/save_and_rewrite_header/save_and_rewrite_header.py b/plugins/save_and_rewrite_header/save_and_rewrite_header.py index 869a8109..4e10206f 100644 --- a/plugins/save_and_rewrite_header/save_and_rewrite_header.py +++ b/plugins/save_and_rewrite_header/save_and_rewrite_header.py @@ -23,9 +23,9 @@ PLUGIN_NAME = "Save and rewrite header" PLUGIN_AUTHOR = "Nicolas Cenerario" PLUGIN_DESCRIPTION = "This plugin adds a context menu action to save files and rewrite their header." -PLUGIN_VERSION = "0.2" +PLUGIN_VERSION = "0.3" PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15", "2.0"] -PLUGIN_LICENSE = "GPL-3.0" +PLUGIN_LICENSE = "GPL-3.0-or-later" PLUGIN_LICENSE_URL = "http://www.gnu.org/licenses/gpl-3.0.txt" from _functools import partial diff --git a/plugins/smart_title_case/smart_title_case.py b/plugins/smart_title_case/smart_title_case.py new file mode 100644 index 00000000..eceb7ef8 --- /dev/null +++ b/plugins/smart_title_case/smart_title_case.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# This is the Smart Title Case plugin for MusicBrainz Picard. +# Copyright (C) 2017 Sophist. +# +# Updated for use with Picard v2.0 by Bob Swift (rdswift). +# +# It is based on the Title Case plugin by Javier Kohen +# Copyright 2007 Javier Kohen +# +# 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. + +PLUGIN_NAME = "Smart Title Case" +PLUGIN_AUTHOR = "Sophist based on an earlier plugin by Javier Kohen" +PLUGIN_DESCRIPTION = """ +Capitalize First Character In Every Word Of Album/Track Title/Artist.
+Leaves words containing embedded uppercase as-is i.e. USA or DoA.
+For Artist/AlbumArtist, title cases only artists not join phrases
+e.g. The Beatles feat. The Who. +""" +PLUGIN_VERSION = "0.3" +PLUGIN_API_VERSIONS = ["2.0"] +PLUGIN_LICENSE = "GPL-2.0-or-later" +PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-3.0.html" + +import re, unicodedata + +title_tags = ['title', 'album'] +artist_tags = [ + ('artist', 'artists'), + ('artistsort', '~artists_sort'), + ('albumartist', '~albumartists'), + ('albumartistsort', '~albumartists_sort'), + ] +title_re = re.compile(r'\w[^-,/\s\u2010\u2011]*') + +def match_word(match): + word = match.group(0) + if word == word.lower(): + word = word[0].upper() + word[1:] + return word + +def string_title_match(match_word, string): + return title_re.sub(match_word, string) + +def string_cleanup(string): + if not string: + return "" + return unicodedata.normalize("NFKC", string) + +def string_title_case(string): + """Title-case a string using a less destructive method than str.title.""" + return string_title_match(match_word, string_cleanup(string)) + + +def artist_title_case(text, artists, artists_upper): + """ + Use the array of artists and the joined string + to identify artists to make title case + and the join strings to leave as-is. + """ + find = "^(" + r")(\s+\S+?\s+)(".join((map(re.escape, map(string_cleanup,artists)))) + ")(.*$)" + replace = "".join([r"%s\%d" % (a, x*2 + 2) for x, a in enumerate(artists_upper)]) + result = re.sub(find, replace, string_cleanup(text)) + return result + + +################################################ +# Uncomment the following to enable unit tests # +################################################ +# +# assert "Make Title Case" == string_title_case("make title case") +# assert "Already Title Case" == string_title_case("Already Title Case") +# assert "mIxEd cAsE" == string_title_case("mIxEd cAsE") +# assert "A" == string_title_case("a") +# assert "Apostrophe's Apostrophe's" == string_title_case("apostrophe's apostrophe's") +# assert "(Bracketed Text)" == string_title_case("(bracketed text)") +# assert "'Single Quotes'" == string_title_case("'single quotes'") +# assert '"Double Quotes"' == string_title_case('"double quotes"') +# assert "A,B" == string_title_case("a,b") +# assert "A-B" == string_title_case("a-b") +# assert "A/B" == string_title_case("a/b") +# assert "Flügel" == string_title_case("flügel") +# assert "HARVEST STORY By 杉山清貴" == string_title_case("HARVEST STORY by 杉山清貴") +# assert "The Beatles feat. The Who" == artist_title_case( +# "the beatles feat. the who", +# ["the beatles", "the who"], +# ["The Beatles", "The Who"] +# ) + + +# Put this here so that above unit tests can run standalone before getting an import error +from picard import log +from picard.metadata import ( + register_track_metadata_processor, + register_album_metadata_processor, +) + +def title_case(tagger, metadata, release, track=None): + for name in title_tags: + if name in metadata: + values = metadata.getall(name) + new_values = [string_title_case(v) for v in values] + if values != new_values: + log.debug("SmartTitleCase: %s: %r replaced with %r", name, values, new_values) + metadata[name] = new_values + for artist_string, artists_list in artist_tags: + if artist_string in metadata and artists_list in metadata: + artist = metadata.getall(artist_string) + artists = metadata.getall(artists_list) + new_artists = list(map(string_title_case, artists)) + new_artist = [artist_title_case(x, artists, new_artists) for x in artist] + if artists != new_artists and artist != new_artist: + log.debug("SmartTitleCase: %s: %s replaced with %s", artist_string, artist, new_artist) + log.debug("SmartTitleCase: %s: %r replaced with %r", artists_list, artists, new_artists) + metadata[artist_string] = new_artist + metadata[artists_list] = new_artists + elif artists != new_artists or artist != new_artist: + if artists != new_artists: + log.warning("SmartTitleCase: %s changed, %s wasn't", artists_list, artist_string) + log.warning("SmartTitleCase: %s: %r changed to %r", artists_list, artists, new_artists) + log.warning("SmartTitleCase: %s: %r unchanged", artist_string, artist) + else: + log.warning("SmartTitleCase: %s changed, %s wasn't", artist_string, artists_list) + log.warning("SmartTitleCase: %s: %r changed to %r", artist_string, artist, new_artist) + log.warning("SmartTitleCase: %s: %r unchanged", artists_list, artists) + +register_track_metadata_processor(title_case) +register_album_metadata_processor(title_case) diff --git a/plugins/sort_multivalue_tags/sort_multivalue_tags.py b/plugins/sort_multivalue_tags/sort_multivalue_tags.py index c5f92f98..020a1f67 100644 --- a/plugins/sort_multivalue_tags/sort_multivalue_tags.py +++ b/plugins/sort_multivalue_tags/sort_multivalue_tags.py @@ -23,9 +23,9 @@
  • The sequence of one tag is linked to the sequence of another e.g. Label and Catalogue number.
  • ''' -PLUGIN_VERSION = "0.3" +PLUGIN_VERSION = "0.4" PLUGIN_API_VERSIONS = ["0.15", "2.0"] -PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from picard.metadata import register_track_metadata_processor diff --git a/plugins/soundtrack/soundtrack.py b/plugins/soundtrack/soundtrack.py index 3afb9958..6fb3c3b2 100644 --- a/plugins/soundtrack/soundtrack.py +++ b/plugins/soundtrack/soundtrack.py @@ -10,7 +10,7 @@ PLUGIN_LICENSE = 'WTFPL' PLUGIN_LICENSE_URL = 'http://www.wtfpl.net/' PLUGIN_DESCRIPTION = '''Sets the albumartist to "Soundtrack" if releasetype is a soundtrack.''' -PLUGIN_VERSION = "0.1" +PLUGIN_VERSION = "0.2" PLUGIN_API_VERSIONS = ["1.0", "2.0"] from picard.metadata import register_album_metadata_processor @@ -19,5 +19,6 @@ def soundtrack(tagger, metadata, release): if "soundtrack" in metadata["releasetype"]: metadata["albumartist"] = "Soundtrack" + metadata["albumartistsort"] = "Soundtrack" register_album_metadata_processor(soundtrack) diff --git a/plugins/standardise_performers/standardise_performers.py b/plugins/standardise_performers/standardise_performers.py index 484d3f99..b1429f87 100644 --- a/plugins/standardise_performers/standardise_performers.py +++ b/plugins/standardise_performers/standardise_performers.py @@ -22,7 +22,7 @@ Performer [tambourine]: Graham Gouldman ''' -PLUGIN_VERSION = '0.3' +PLUGIN_VERSION = '0.4' PLUGIN_API_VERSIONS = ["0.15.0", "0.15.1", "0.16.0", "1.0.0", "1.1.0", "1.2.0", "1.3.0", "2.0"] PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" @@ -49,8 +49,17 @@ def standardise_performers(album, metadata, *args): PLUGIN_NAME, subkey, ) + prefixes = [] + words = instruments[0].split() + for word in words[:]: + if not word in ['guest', 'solo', 'additional', 'minor']: + break + prefixes.append(word) + words.remove(word) + instruments[0] = " ".join(words) + prefix = " ".join(prefixes) + " " if prefixes else "" for instrument in instruments: - newkey = '%s:%s' % (mainkey, instrument) + newkey = '%s:%s%s' % (mainkey, prefix, instrument) for value in values: metadata.add_unique(newkey, value) del metadata[key] diff --git a/plugins/tangoinfo/tangoinfo.py b/plugins/tangoinfo/tangoinfo.py index ed92ad55..380e8ba2 100644 --- a/plugins/tangoinfo/tangoinfo.py +++ b/plugins/tangoinfo/tangoinfo.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- PLUGIN_NAME = "Tango.info Adapter" -PLUGIN_AUTHOR = "Felix Elsner, Sambhav Kothari" +PLUGIN_AUTHOR = "Felix Elsner, Sambhav Kothari, Philipp Wolfer" PLUGIN_DESCRIPTION = """

    Load genre, date and vocalist tags from the online database tango.info.

    @@ -8,7 +8,7 @@ it does not cause unnecessary server load for either MusicBrainz.org or tango.info

    """ -PLUGIN_VERSION = "1.0" +PLUGIN_VERSION = "1.1" PLUGIN_API_VERSIONS = ["2.0"] PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" @@ -98,18 +98,16 @@ def add_tangoinfo_data(self, album, track_metadata, # ) return - tint = str("0%s-%s-%s" % ( - barcode, - str(track_metadata["discnumber"])\ - if track_metadata.get("discnumber") else "1", - str(track_metadata["tracknumber"]) - )) + tint = "0%s-%s-%s" % ( + barcode, + str(track_metadata.get("discnumber", "1")), + str(track_metadata["tracknumber"])) if barcode in self.albumpage_cache: if self.albumpage_cache[barcode]: if not self.albumpage_cache[barcode].get(tint): - log.debug("%s: No information on tango.info for barcode \ - %s" % (PLUGIN_NAME, barcode)) + log.debug("%s: No information on tango.info for barcode %s", + PLUGIN_NAME, barcode) else: for field in ['genre', 'date', 'vocal']: # Checks, as not to overwrite with empty data @@ -117,8 +115,8 @@ def add_tangoinfo_data(self, album, track_metadata, track_metadata[field] = self\ .albumpage_cache[barcode][tint][field] else: - log.debug("%s: No information on tango.info for barcode %s" \ - % (PLUGIN_NAME, barcode)) + log.debug("%s: No information on tango.info for barcode %s", + PLUGIN_NAME, barcode) else: #log.debug("%s: Adding to queue: %s, new track: %s" \ # % (PLUGIN_NAME, barcode, album._new_tracks[-1])) @@ -133,30 +131,29 @@ def website_add_track(self, album, track, barcode, tint, zeros=0): if self.albumpage_queue.append(barcode, (track, album, tint)): log.debug("%s: Downloading from tango.info: track %s, album %s, \ - with TINT %s" % (\ - PLUGIN_NAME, str(track), str(album), tint) - ) + with TINT %s", PLUGIN_NAME, str(track), str(album), tint) host = 'tango.info' port = 443 path = '/%s' % (barcode) # Call website_process as a partial func - return album.tagger.webservice.get(host, port, path, + return album.tagger.webservice.download(host, port, path, partial(self.website_process, barcode, zeros), - parse_response_type=None, priority=False, important=False) + priority=False, important=False) def website_process(self, barcode, zeros, response, reply, error): if error: - log.error("%s: Error retrieving info for barcode %s" % \ - PLUGIN_NAME, barcode) + log.error("%s: Error retrieving info for barcode %s", + PLUGIN_NAME, barcode) tuples = self.albumpage_queue.remove(barcode) - for track, album in tuples: + for track, album, tint in tuples: self.album_remove_request(album) return - tangoinfo_album_data = self.barcode_process_metadata(barcode, response) + html = bytes(response).decode() + tangoinfo_album_data = self.barcode_process_metadata(barcode, html) self.albumpage_cache[barcode] = tangoinfo_album_data tuples = self.albumpage_queue.remove(barcode) @@ -164,13 +161,12 @@ def website_process(self, barcode, zeros, response, reply, error): if tangoinfo_album_data: if zeros > 0: log.debug("%s: " - "tango.info does not seem to have data for barcode %s. However, " - "retrying with barcode %s (i.e. the same with 0 prepended) was " - "successful. This most likely means either MusicBrainz or " - "tango.info has stored a wrong barcode for this release. You might " - "want to investigate this discrepancy and report it." \ - % (PLUGIN_NAME, barcode[zeros:], barcode) - ) + "tango.info does not seem to have data for barcode %s. However, " + "retrying with barcode %s (i.e. the same with 0 prepended) was " + "successful. This most likely means either MusicBrainz or " + "tango.info has stored a wrong barcode for this release. You might " + "want to investigate this discrepancy and report it.", + PLUGIN_NAME, barcode[zeros:], barcode) for track, album, tint in tuples: tm = track.metadata @@ -193,22 +189,21 @@ def website_process(self, barcode, zeros, response, reply, error): "Could not load album with barcode %s even with zero " "prepended(%s). This most likely means tango.info does " "not have a release for this barcode (or MusicBrainz has a " - "wrong barcode)" \ - % (PLUGIN_NAME, barcode[1:], barcode) - ) + "wrong barcode)", + PLUGIN_NAME, barcode[1:], barcode) for track, album, tint in tuples: self.album_remove_request(album) return - log.debug("%s: Retrying with 0-padded barcode for barcode %s" % \ - (PLUGIN_NAME, barcode)) + log.debug("%s: Retrying with 0-padded barcode for barcode %s", + PLUGIN_NAME, barcode) for track, album, tint in tuples: retry_barcode = "0" + str(barcode) retry_tint = "0" + tint # Try again with new barcode, but at most two times(param zero) self.website_add_track( - album, track, retry_barcode, retry_tint, zeros=(zeros+1) + album, track, retry_barcode, retry_tint, zeros=(zeros+1) ) self.album_remove_request(album) @@ -224,11 +219,16 @@ def barcode_process_metadata(self, barcode, response): # Check whether we have a concealed 404 and get the homepage if "Contents - tango.info" in response: - log.debug("%s: No album with barcode %s on tango.info" % \ - (PLUGIN_NAME, barcode)) + log.debug("%s: No album with barcode %s on tango.info", + PLUGIN_NAME, barcode) + return + + table = table_regex.search(response) + if not table: + log.warning("%s: No table found on page for barcode %s on tango.info", + PLUGIN_NAME, barcode) return - table = re.findall(table_regex, response)[0] albuminfo = {} trcontent = [match.groups()[0] for match in trs.finditer(table)] diff --git a/plugins/theaudiodb/__init__.py b/plugins/theaudiodb/__init__.py new file mode 100644 index 00000000..38967ca7 --- /dev/null +++ b/plugins/theaudiodb/__init__.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015-2019 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. + +PLUGIN_NAME = 'TheAudioDB cover art' +PLUGIN_AUTHOR = 'Philipp Wolfer' +PLUGIN_DESCRIPTION = 'Use cover art from TheAudioDB.' +PLUGIN_VERSION = "1.0.2" +PLUGIN_API_VERSIONS = ["2.0", "2.1", "2.2"] +PLUGIN_LICENSE = "GPL-2.0-or-later" +PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" + +from PyQt5.QtCore import QUrl +from PyQt5.QtNetwork import QNetworkReply +from picard import config, log +from picard.coverart.providers import ( + CoverArtProvider, + register_cover_art_provider, +) +from picard.coverart.image import CoverArtImage +from picard.ui.options import ( + register_options_page, + OptionsPage, +) +from picard.config import TextOption +from .ui_options_theaudiodb import Ui_TheAudioDbOptionsPage + +THEAUDIODB_HOST = "www.theaudiodb.com" +THEAUDIODB_PORT = 443 +THEAUDIODB_APIKEY = "195003" + +OPTION_CDART_ALWAYS = "always" +OPTION_CDART_NEVER = "never" +OPTION_CDART_NOALBUMART = "noalbumart" + + +class TheAudioDbCoverArtImage(CoverArtImage): + + """Image from The Audio DB""" + + support_types = True + sourceprefix = "AUDIODB" + + def parse_url(self, url): + super().parse_url(url) + # Workaround for Picard always returning port 80 regardless of the + # scheme. No longer necessary for Picard >= 2.1.3 + self.port = self.url.port(443 if self.url.scheme() == 'https' else 80) + + +class CoverArtProviderTheAudioDb(CoverArtProvider): + + """Use TheAudioDB to get cover art""" + + NAME = "TheAudioDB" + + def enabled(self): + return super().enabled() and not self.coverart.front_image_found + + def queue_images(self): + release_group_id = self.metadata["musicbrainz_releasegroupid"] + path = "/api/v1/json/%s/album-mb.php" % (THEAUDIODB_APIKEY, ) + queryargs = { + "i": bytes(QUrl.toPercentEncoding(release_group_id)).decode() + } + log.debug("TheAudioDB: Queued download: %s?i=%s", path, queryargs["i"]) + self.album.tagger.webservice.get( + THEAUDIODB_HOST, + THEAUDIODB_PORT, + path, + self._json_downloaded, + priority=True, + important=False, + parse_response_type='json', + queryargs=queryargs) + self.album._requests += 1 + return CoverArtProvider.WAIT + + def _json_downloaded(self, data, reply, error): + self.album._requests -= 1 + + if error: + if error != QNetworkReply.ContentNotFoundError: + error_level = log.error + else: + error_level = log.debug + error_level("TheAudioDB: Problem requesting metadata: %s", error) + else: + try: + releases = data.get("album") + if not releases: + log.debug("TheAudioDB: No cover art found for %s", + reply.url().url()) + return + release = releases[0] + albumart_url = release.get("strAlbumThumb") + cdart_url = release.get("strAlbumCDart") + use_cdart = config.setting["theaudiodb_use_cdart"] + + if albumart_url: + self._select_and_add_cover_art(albumart_url, ["front"]) + + if cdart_url and (use_cdart == OPTION_CDART_ALWAYS + or (use_cdart == OPTION_CDART_NOALBUMART + and not albumart_url)): + types = ["medium"] + if not albumart_url: + types.append("front") + self._select_and_add_cover_art(cdart_url, types) + except (TypeError): + log.error("TheAudioDB: Problem processing downloaded metadata", exc_info=True) + finally: + self.next_in_queue() + + def _select_and_add_cover_art(self, url, types): + log.debug("TheAudioDB: Found artwork %s" % url) + self.queue_put(TheAudioDbCoverArtImage(url, types=types)) + + +class TheAudioDbOptionsPage(OptionsPage): + + NAME = "theaudiodb" + TITLE = "TheAudioDB" + PARENT = "plugins" + + options = [ + TextOption("setting", "theaudiodb_use_cdart", OPTION_CDART_NOALBUMART), + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.ui = Ui_TheAudioDbOptionsPage() + self.ui.setupUi(self) + + def load(self): + if config.setting["theaudiodb_use_cdart"] == OPTION_CDART_ALWAYS: + self.ui.theaudiodb_cdart_use_always.setChecked(True) + elif config.setting["theaudiodb_use_cdart"] == OPTION_CDART_NEVER: + self.ui.theaudiodb_cdart_use_never.setChecked(True) + elif config.setting["theaudiodb_use_cdart"] == OPTION_CDART_NOALBUMART: + self.ui.theaudiodb_cdart_use_if_no_albumcover.setChecked(True) + + def save(self): + if self.ui.theaudiodb_cdart_use_always.isChecked(): + config.setting["theaudiodb_use_cdart"] = OPTION_CDART_ALWAYS + elif self.ui.theaudiodb_cdart_use_never.isChecked(): + config.setting["theaudiodb_use_cdart"] = OPTION_CDART_NEVER + elif self.ui.theaudiodb_cdart_use_if_no_albumcover.isChecked(): + config.setting["theaudiodb_use_cdart"] = OPTION_CDART_NOALBUMART + + +register_cover_art_provider(CoverArtProviderTheAudioDb) +register_options_page(TheAudioDbOptionsPage) diff --git a/plugins/theaudiodb/options_theaudiodb.ui b/plugins/theaudiodb/options_theaudiodb.ui new file mode 100644 index 00000000..f811b4f7 --- /dev/null +++ b/plugins/theaudiodb/options_theaudiodb.ui @@ -0,0 +1,133 @@ + + + TheAudioDbOptionsPage + + + + 0 + 0 + 442 + 364 + + + + + 6 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + TheAudioDB cover art + + + + 2 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Cantarell'; font-size:10pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">This plugin loads cover art from <a href="https://www.theaudiodb.com"><span style=" text-decoration: underline; color:#0000ff;">TheAudioDB</span></a>. If you want to improve the results of this plugin please contribute.</p></body></html> + + + Qt::RichText + + + true + + + true + + + + + + + + + + Medium images + + + + + + Always load medium images + + + + + + + Load only if no front cover is available + + + + + + + Never load medium images + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/plugins/theaudiodb/ui_options_theaudiodb.py b/plugins/theaudiodb/ui_options_theaudiodb.py new file mode 100644 index 00000000..b13fb8aa --- /dev/null +++ b/plugins/theaudiodb/ui_options_theaudiodb.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'options_theaduiodb.ui' +# +# Created by: PyQt5 UI code generator 5.12 +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_TheAudioDbOptionsPage(object): + def setupUi(self, TheAudioDbOptionsPage): + TheAudioDbOptionsPage.setObjectName("TheAudioDbOptionsPage") + TheAudioDbOptionsPage.resize(442, 364) + self.vboxlayout = QtWidgets.QVBoxLayout(TheAudioDbOptionsPage) + self.vboxlayout.setContentsMargins(9, 9, 9, 9) + self.vboxlayout.setSpacing(6) + self.vboxlayout.setObjectName("vboxlayout") + self.groupBox = QtWidgets.QGroupBox(TheAudioDbOptionsPage) + self.groupBox.setObjectName("groupBox") + self.vboxlayout1 = QtWidgets.QVBoxLayout(self.groupBox) + self.vboxlayout1.setContentsMargins(9, 9, 9, 9) + self.vboxlayout1.setSpacing(2) + self.vboxlayout1.setObjectName("vboxlayout1") + self.label = QtWidgets.QLabel(self.groupBox) + self.label.setTextFormat(QtCore.Qt.RichText) + self.label.setWordWrap(True) + self.label.setOpenExternalLinks(True) + self.label.setObjectName("label") + self.vboxlayout1.addWidget(self.label) + self.vboxlayout.addWidget(self.groupBox) + self.verticalGroupBox = QtWidgets.QGroupBox(TheAudioDbOptionsPage) + self.verticalGroupBox.setObjectName("verticalGroupBox") + self.verticalLayout = QtWidgets.QVBoxLayout(self.verticalGroupBox) + self.verticalLayout.setObjectName("verticalLayout") + self.theaudiodb_cdart_use_always = QtWidgets.QRadioButton(self.verticalGroupBox) + self.theaudiodb_cdart_use_always.setObjectName("theaudiodb_cdart_use_always") + self.verticalLayout.addWidget(self.theaudiodb_cdart_use_always) + self.theaudiodb_cdart_use_if_no_albumcover = QtWidgets.QRadioButton(self.verticalGroupBox) + self.theaudiodb_cdart_use_if_no_albumcover.setObjectName("theaudiodb_cdart_use_if_no_albumcover") + self.verticalLayout.addWidget(self.theaudiodb_cdart_use_if_no_albumcover) + self.theaudiodb_cdart_use_never = QtWidgets.QRadioButton(self.verticalGroupBox) + self.theaudiodb_cdart_use_never.setObjectName("theaudiodb_cdart_use_never") + self.verticalLayout.addWidget(self.theaudiodb_cdart_use_never) + self.vboxlayout.addWidget(self.verticalGroupBox) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.vboxlayout.addItem(spacerItem) + spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.vboxlayout.addItem(spacerItem1) + + self.retranslateUi(TheAudioDbOptionsPage) + QtCore.QMetaObject.connectSlotsByName(TheAudioDbOptionsPage) + + def retranslateUi(self, TheAudioDbOptionsPage): + _translate = QtCore.QCoreApplication.translate + self.groupBox.setTitle(_translate("TheAudioDbOptionsPage", "TheAudioDB cover art")) + self.label.setText(_translate("TheAudioDbOptionsPage", "\n" +"\n" +"

    This plugin loads cover art from TheAudioDB. If you want to improve the results of this plugin please contribute.

    ")) + self.verticalGroupBox.setTitle(_translate("TheAudioDbOptionsPage", "Medium images")) + self.theaudiodb_cdart_use_always.setText(_translate("TheAudioDbOptionsPage", "Always load medium images")) + self.theaudiodb_cdart_use_if_no_albumcover.setText(_translate("TheAudioDbOptionsPage", "Load only if no front cover is available")) + self.theaudiodb_cdart_use_never.setText(_translate("TheAudioDbOptionsPage", "Never load medium images")) + + diff --git a/plugins/videotools/__init__.py b/plugins/videotools/__init__.py index 42c741b7..b163d7cf 100644 --- a/plugins/videotools/__init__.py +++ b/plugins/videotools/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2014, 2017 Philipp Wolfer +# Copyright (C) 2014, 2017, 2019 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -19,15 +19,24 @@ PLUGIN_NAME = 'Video tools' PLUGIN_AUTHOR = 'Philipp Wolfer' -PLUGIN_DESCRIPTION = 'Improves the video support in Picard by adding support for Matroska, WebM, AVI, QuickTime and MPEG files (renaming and fingerprinting only, no tagging) and providing $is_audio() and $is_video() scripting functions.' -PLUGIN_VERSION = "0.2" -PLUGIN_API_VERSIONS = ["1.3.0", "2.0"] -PLUGIN_LICENSE = "GPL-2.0" +PLUGIN_DESCRIPTION = ('Improves the video support in Picard by adding support ' + 'for Matroska, WebM, AVI, QuickTime and MPEG files ' + '(renaming and fingerprinting only, no tagging) and ' + 'providing $is_audio() and $is_video() scripting ' + 'functions.') +PLUGIN_VERSION = "0.4" +PLUGIN_API_VERSIONS = ["2.0", "2.1", "2.2"] +PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from picard.formats import register_format from picard.script import register_script_function -from picard.plugins.videotools.formats import MatroskaFile, MpegFile, QuickTimeFile, RiffFile +from picard.plugins.videotools.formats import ( + MatroskaFile, + MpegFile, + QuickTimeFile, + RiffFile, +) from picard.plugins.videotools.script import is_audio, is_video diff --git a/plugins/videotools/formats.py b/plugins/videotools/formats.py index 0283524d..978a195c 100644 --- a/plugins/videotools/formats.py +++ b/plugins/videotools/formats.py @@ -1,26 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2014 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. - -from __future__ import absolute_import -# -*- coding: utf-8 -*- -# -# Copyright (C) 2014 Philipp Wolfer +# Copyright (C) 2014, 2019 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -40,8 +20,6 @@ from . import enzyme from picard import log from picard.file import File -from picard.formats import register_format -from picard.formats.wav import WAVFile from picard.metadata import Metadata @@ -54,7 +32,7 @@ def _load(self, filename): try: parser = enzyme.parse(filename) - log.debug("Metadata for %s:\n%s", filename, unicode(parser)) + log.debug("Metadata for %s:\n%s", filename, str(parser)) self._convertMetadata(parser, metadata) except Exception as err: log.error("Could not parse file %r: %r", filename, err) diff --git a/plugins/viewvariables/__init__.py b/plugins/viewvariables/__init__.py index 44169407..fb383508 100644 --- a/plugins/viewvariables/__init__.py +++ b/plugins/viewvariables/__init__.py @@ -3,7 +3,7 @@ PLUGIN_NAME = 'View script variables' PLUGIN_AUTHOR = 'Sophist' PLUGIN_DESCRIPTION = '''Display a dialog box listing the metadata variables for the track / file.''' -PLUGIN_VERSION = '0.6' +PLUGIN_VERSION = '0.7' PLUGIN_API_VERSIONS = ['2.0'] PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" @@ -87,7 +87,7 @@ def _display_metadata(self, metadata): i += 1 key_item.setText("_" + key[1:] if key.startswith('~') else key) if key in metadata: - value = dict.get(metadata, key) + value = metadata.getall(key) if len(value) == 1 and value[0] != '': value = value[0] else: diff --git a/plugins/wikidata/.__init__.py.un~ b/plugins/wikidata/.__init__.py.un~ new file mode 100644 index 00000000..f307857d Binary files /dev/null and b/plugins/wikidata/.__init__.py.un~ differ diff --git a/plugins/wikidata/__init__.py b/plugins/wikidata/__init__.py new file mode 100644 index 00000000..e71251eb --- /dev/null +++ b/plugins/wikidata/__init__.py @@ -0,0 +1,394 @@ +# -*- coding: utf-8 -*- +# Copyright © 2016 Daniel sobey + +# This work is free. You can redistribute it and/or modify it under the +# terms of the Do What The Fuck You Want To Public License, Version 2, +# as published by Sam Hocevar. See http://www.wtfpl.net/ for more details. + +PLUGIN_NAME = 'Wikidata Genre' +PLUGIN_AUTHOR = 'Daniel Sobey, Sambhav Kothari' +PLUGIN_DESCRIPTION = 'query wikidata to get genre tags' +PLUGIN_VERSION = '1.3' +PLUGIN_API_VERSIONS = ["2.0"] +PLUGIN_LICENSE = 'WTFPL' +PLUGIN_LICENSE_URL = 'http://www.wtfpl.net/' + +import re +from functools import partial +from picard import config, log +from picard.metadata import register_track_metadata_processor +from picard.plugins.wikidata.ui_options_wikidata import Ui_WikidataOptionsPage +from picard.ui.options import register_options_page, OptionsPage + + +def parse_ignored_tags(ignore_tags_setting): + ignore_tags = [] + for tag in ignore_tags_setting.lower().split(','): + if not tag: + break + tag = tag.strip() + if tag.startswith('/') and tag.endswith('/'): + try: + tag = re.compile(tag[1:-1]) + except re.error: + log.error( + 'Error parsing ignored tag "%s"', tag, exc_info=True) + ignore_tags.append(tag) + return ignore_tags + + +def matches_ignored(ignore_tags, tag): + if ignore_tags: + tag = tag.lower().strip() + for pattern in ignore_tags: + if hasattr(pattern, 'match'): + match = pattern.match(tag) + else: + match = pattern == tag + if match: + return True + return False + + +class Wikidata: + + RELEASE_GROUP = 1 + ARTIST = 2 + WORK = 3 + + def __init__(self): + # Key: mbid, value: List of metadata entries to be updated when we have parsed everything + self.requests = {} + + # Key: mbid, value: List of items to track the number of outstanding requests + self.itemAlbums = {} + + # cache, items that have been found + # key: mbid, value: list of strings containing the genre's + self.cache = {} + + # metabrainz url + self.mb_host = '' + self.mb_port = '' + + # web service & logger + self.ws = None + self.log = None + + # settings from options, options + self.use_release_group_genres = False + self.use_artist_genres = False + self.use_artist_only_if_no_release = False + self.ignore_genres_from_these_artists = '' + self.ignore_genres_from_these_artists_list = [] + self.use_work_genres = True + self.ignore_these_genres = '' + self.ignore_these_genres_list = [] + self.genre_delimiter = '' + + # not used + def process_release(self, album, metadata, release): + self.ws = album.tagger.webservice + self.log = album.log + item_id = dict.get(metadata, 'musicbrainz_releasegroupid')[0] + + log.info('WIKIDATA: Processing release group %s ' % item_id) + self.process_request(metadata, album, item_id, item_type='release-group') + for artist in dict.get(metadata, 'musicbrainz_albumartistid'): + item_id = artist + log.info('WIKIDATA: Processing release artist %s' % item_id) + self.process_request(metadata, album, item_id, item_type='artist') + + # Main processing function + # First see if we have already found what we need in the cache, finalize loading + # Next see if we are already looking for the item + # If we are, add this item to the list of items to be updated once we find what we are looking for. + # Otherwise we are the first one to look up this item, start a new request + # metadata, map containing the new metadata + # + def process_request(self, metadata, album, item_id, item_type): + log.debug('WIKIDATA: Looking up cache for item: %s' % item_id) + log.debug('WIKIDATA: Album request count: %s' % album._requests) + log.debug('WIKIDATA: Item type %s' % item_type) + if item_id in self.cache: + log.debug('WIKIDATA: Found item in cache') + genre_list = self.cache[item_id] + new_genre = set(metadata.getall("genre")) + new_genre.update(genre_list) + #sort the new genre list so that they don't appear as new entries (not a change) next time + metadata["genre"] = self.genre_delimiter.join(sorted(new_genre)) + return + else: + # pending requests are handled by adding the metadata object to a + # list of things to be updated when the genre is found + if item_id in self.itemAlbums: + log.debug( + 'WIKIDATA: Request already pending, add it to the list of items to update once this has been' + 'found') + self.requests[item_id].append(metadata) + else: + self.requests[item_id] = [metadata] + self.itemAlbums[item_id] = album + album._requests += 1 + + log.debug('WIKIDATA: First request for this item') + log.debug('WIKIDATA: About to call Musicbrainz to look up %s ' % item_id) + + path = '/ws/2/%s/%s' % (item_type, item_id) + queryargs = {"inc": "url-rels"} + + self.ws.get(self.mb_host, self.mb_port, path, partial(self.musicbrainz_release_lookup, item_id, + metadata), + parse_response_type="xml", priority=False, important=False, queryargs=queryargs) + + def musicbrainz_release_lookup(self, item_id, metadata, response, reply, error): + found = False + if error: + log.error('WIKIDATA: Error retrieving release group info') + else: + if 'metadata' in response.children: + if 'release_group' in response.metadata[0].children and self.use_release_group_genres: + if 'relation_list' in response.metadata[0].release_group[0].children: + for relation in response.metadata[0].release_group[0].relation_list[0].relation: + if relation.type == 'wikidata' and 'target' in relation.children: + found = True + wikidata_url = relation.target[0].text + log.debug('WIKIDATA: wikidata url found for RELEASE_GROUP: %s ', wikidata_url) + self.process_wikidata(Wikidata.RELEASE_GROUP, wikidata_url, item_id) + if 'artist' in response.metadata[0].children and self.use_artist_genres: + if 'relation_list' in response.metadata[0].artist[0].children: + for relation in response.metadata[0].artist[0].relation_list[0].relation: + if relation.type == 'wikidata' and 'target' in relation.children: + found = True + wikidata_url = relation.target[0].text + self.process_wikidata(Wikidata.ARTIST, wikidata_url, item_id) + log.debug('WIKIDATA: wikidata url found for ARTIST: %s ', wikidata_url) + if 'work' in response.metadata[0].children and self.use_work_genres: + if 'relation_list' in response.metadata[0].work[0].children: + for relation in response.metadata[0].work[0].relation_list[0].relation: + if relation.type == 'wikidata' and 'target' in relation.children: + found = True + wikidata_url = relation.target[0].text + log.debug('WIKIDATA: wikidata url found for WORK: %s ', wikidata_url) + self.process_wikidata(Wikidata.WORK, wikidata_url, item_id) + if not found: + log.debug('WIKIDATA: No wikidata url found for item_id: %s ', item_id) + + album = self.itemAlbums[item_id] + album._requests -= 1 + if not album._requests: + self.itemAlbums = {k: v for k, v in self.itemAlbums.items() if v != album} + album._finalize_loading(None) + log.info('WIKIDATA: Total remaining requests: %s' % album._requests) + if not self.itemAlbums: + self.requests.clear() + log.info('WIKIDATA: Finished (A)') + + def process_wikidata(self, genre_source_type, wikidata_url, item_id): + album = self.itemAlbums[item_id] + album._requests += 1 + item = wikidata_url.split('/')[4] + path = "/wiki/Special:EntityData/" + item + ".rdf" + log.debug('WIKIDATA: Fetching from wikidata.org%s' % path) + self.ws.get('www.wikidata.org', 443, path, + partial(self.parse_wikidata_response, item, item_id, genre_source_type), + parse_response_type="xml", priority=False, important=False) + + def parse_wikidata_response(self, item, item_id, genre_source_type, response, reply, error): + genre_entries = [] + genre_list = [] + if error: + log.error('WIKIDATA: error getting data from wikidata.org') + else: + if 'RDF' in response.children: + node = response.RDF[0] + for node1 in node.Description: + if 'about' in node1.attribs: + if node1.attribs.get('about') == 'http://www.wikidata.org/entity/%s' % item: + for key, val in list(node1.children.items()): + if key == 'P136': + for i in val: + if 'resource' in i.attribs: + tmp = i.attribs.get('resource') + if 'entity' == tmp.split('/')[3] and len(tmp.split('/')) == 5: + genre_id = tmp.split('/')[4] + log.debug( + 'WIKIDATA: Found the wikidata id for the genre: %s' % genre_id) + genre_entries.append(tmp) + else: + for tmp in genre_entries: + if tmp == node1.attribs.get('about'): + list1 = node1.children.get('name') + for node2 in list1: + if node2.attribs.get('lang') == 'en': + genre = node2.text.title() + if not matches_ignored(self.ignore_these_genres_list, genre): + genre_list.append(genre) + log.debug('New genre has been found and ALLOWED: %s' % genre) + else: + log.debug('New genre has been found, but IGNORED: %s' % genre) + + if len(genre_list) > 0: + log.debug('WIKIDATA: item_id: %s' % item_id) + log.debug('WIKIDATA: Final list of wikidata id found: %s' % genre_entries) + log.debug('WIKIDATA: Final list of genre: %s' % genre_list) + log.info('WIKIDATA: Total items to update: %d ' % len(self.requests[item_id])) + + for metadata in self.requests[item_id]: + if genre_source_type == Wikidata.RELEASE_GROUP: + metadata['~release_group_genre_sourced'] = True + elif genre_source_type == Wikidata.ARTIST: + if self.use_artist_only_if_no_release and metadata['~release_group_genre_sourced'] or \ + matches_ignored(self.ignore_genres_from_these_artists_list, metadata.get("artist")): + if item_id not in self.cache: + self.cache[item_id] = [] + log.debug('WIKIDATA: NOT setting Artist-sourced genre: %s ' % genre_list) + continue + else: + log.debug('WIKIDATA: Setting Artist-sourced genre: %s ' % genre_list) + + # getall doesn't handle delimiters so we need to check-n-parse here + old_genre_metadata = metadata.getall("genre") + old_genre_list = [] + for genre in old_genre_metadata: + if self.genre_delimiter and self.genre_delimiter in genre: + old_genre_list.extend(genre.split(self.genre_delimiter)) + else: + old_genre_list.append(genre) + + new_genre = set(old_genre_list) + new_genre.update(genre_list) + # Sort the new genre list so that they don't appear as new entries (not a change) next time + log.debug('WIKIDATA: setting metadata genre to : %s ' % new_genre) + if self.genre_delimiter: + metadata["genre"] = self.genre_delimiter.join(sorted(new_genre)) + else: + metadata["genre"] = sorted(new_genre) + + log.debug('WIKIDATA: setting cache genre to : %s ' % genre_list) + self.cache[item_id] = genre_list + else: + log.debug('WIKIDATA: genre not found in wikidata') + + log.debug('WIKIDATA: seeing if we can finalize tags...') + + album = self.itemAlbums[item_id] + album._requests -= 1 + if not album._requests: + self.itemAlbums = {k: v for k, v in self.itemAlbums.items() if v != album} + album._finalize_loading(None) + log.info('WIKIDATA: total remaining requests: %s' % album._requests) + if not self.itemAlbums: + self.requests.clear() + log.info('WIKIDATA: Finished (B)') + + def process_track(self, album, metadata, trackXmlNode, releaseXmlNode): + self.update_settings() + self.ws = album.tagger.webservice + self.log = album.log + + log.info('WIKIDATA: Processing Track...') + if self.use_release_group_genres: + for release_group in metadata.getall('musicbrainz_releasegroupid'): + log.debug('WIKIDATA: Looking up release group metadata for %s ' % release_group) + self.process_request(metadata, album, release_group, item_type='release-group') + + if self.use_artist_genres: + for artist in metadata.getall('musicbrainz_albumartistid'): + log.debug('WIKIDATA: Processing release artist %s' % artist) + self.process_request(metadata, album, artist, item_type='artist') + + if self.use_artist_genres: + for artist in metadata.getall('musicbrainz_artistid'): + log.debug('WIKIDATA: Processing track artist %s' % artist) + self.process_request(metadata, album, artist, item_type='artist') + + if self.use_work_genres: + for workid in metadata.getall('musicbrainz_workid'): + log.debug('WIKIDATA: Processing track artist %s' % workid) + self.process_request(metadata, album, workid, item_type='work') + + def update_settings(self): + self.mb_host = config.setting["server_host"] + self.mb_port = config.setting["server_port"] + self.use_release_group_genres = config.setting[""] + self.use_work_genres = config.setting["wikidata_use_work_genres"] + # Some changed settings could invalidate the cache, so clear it to be safe + if self.use_release_group_genres != config.setting["wikidata_use_release_group_genres"]: + self.use_release_group_genres = config.setting["wikidata_use_release_group_genres"] + self.cache.clear() + if self.use_artist_genres != config.setting["wikidata_use_artist_genres"]: + self.use_artist_genres = config.setting["wikidata_use_artist_genres"] + self.cache.clear() + if self.use_artist_only_if_no_release != config.setting["wikidata_use_artist_only_if_no_release"]: + self.use_artist_only_if_no_release = config.setting["wikidata_use_artist_only_if_no_release"] + self.cache.clear() + if self.ignore_genres_from_these_artists != parse_ignored_tags( + config.setting["wikidata_ignore_genres_from_these_artists"]): + self.ignore_genres_from_these_artists_list = parse_ignored_tags( + config.setting["wikidata_ignore_genres_from_these_artists"]) + self.cache.clear() + if self.use_work_genres != config.setting["wikidata_use_work_genres"]: + self.use_work_genres = config.setting["wikidata_use_work_genres"] + self.cache.clear() + if self.ignore_these_genres != parse_ignored_tags(config.setting["wikidata_ignore_these_genres"]): + self.ignore_these_genres = parse_ignored_tags(config.setting["wikidata_ignore_these_genres"]) + self.ignore_these_genres_list = parse_ignored_tags( + config.setting["wikidata_ignore_these_genres"]) + self.cache.clear() + if config.setting["write_id3v23"]: + self.genre_delimiter = config.setting["wikidata_genre_delimiter"] + + +class WikidataOptionsPage(OptionsPage): + NAME = "wikidata" + TITLE = "wikidata-genre" + PARENT = "plugins" + + options = [ + config.BoolOption("setting", "wikidata_use_release_group_genres", True), + config.BoolOption("setting", "wikidata_use_artist_genres", True), + config.BoolOption("setting", "wikidata_use_artist_only_if_no_release", True), + config.TextOption("setting", "wikidata_ignore_genres_from_these_artists", ""), + config.BoolOption("setting", "wikidata_use_work_genres", True), + config.TextOption("setting", "wikidata_ignore_these_genres", "seen live, favorites, /\\d+ of \\d+ stars/"), + config.TextOption("setting", "wikidata_genre_delimiter", "; "), + ] + + def __init__(self, parent=None): + super(WikidataOptionsPage, self).__init__(parent) + self.ui = Ui_WikidataOptionsPage() + self.ui.setupUi(self) + if not config.setting["write_id3v23"]: + self.ui.genre_delimiter.setEnabled(False); + self.ui.genre_delimiter_label.setEnabled(False); + else: + self.ui.genre_delimiter.setEnabled(True); + self.ui.genre_delimiter_label.setEnabled(True); + + def load(self): + setting = config.setting + self.ui.use_release_group_genres.setChecked(setting["wikidata_use_release_group_genres"]) + self.ui.use_artist_genres.setChecked(setting["wikidata_use_artist_genres"]) + self.ui.use_artist_only_if_no_release.setChecked(setting["wikidata_use_artist_only_if_no_release"]) + self.ui.ignore_genres_from_these_artists.setText(setting["wikidata_ignore_genres_from_these_artists"]) + self.ui.use_work_genres.setChecked(setting["wikidata_use_work_genres"]) + self.ui.ignore_these_genres.setText(setting["wikidata_ignore_these_genres"]) + if config.setting["write_id3v23"]: + self.ui.genre_delimiter.setEditText(setting["wikidata_genre_delimiter"]) + + def save(self): + setting = config.setting + setting["wikidata_use_release_group_genres"] = self.ui.use_release_group_genres.isChecked() + setting["wikidata_use_artist_genres"] = self.ui.use_artist_genres.isChecked() + setting["wikidata_use_artist_only_if_no_release"] = self.ui.use_artist_only_if_no_release.isChecked() + setting["wikidata_ignore_genres_from_these_artists"] = str(self.ui.ignore_genres_from_these_artists.text()) + setting["wikidata_use_work_genres"] = self.ui.use_work_genres.isChecked() + setting["wikidata_ignore_these_genres"] = str(self.ui.ignore_these_genres.text()) + if config.setting["write_id3v23"]: + setting["wikidata_genre_delimiter"] = str(self.ui.genre_delimiter.currentText()) + + +wikidata = Wikidata() +register_track_metadata_processor(wikidata.process_track) +register_options_page(WikidataOptionsPage) diff --git a/plugins/wikidata/options_wikidata.ui b/plugins/wikidata/options_wikidata.ui new file mode 100644 index 00000000..d3f2c21f --- /dev/null +++ b/plugins/wikidata/options_wikidata.ui @@ -0,0 +1,438 @@ + + + WikidataOptionsPage + + + + 0 + 0 + 602 + 512 + + + + + + + true + + + + 0 + 0 + + + + + 0 + 0 + + + + Release Group Genre Settings + + + + + + true + + + + 0 + 0 + + + + Use Release Group genres + + + true + + + + + + + + + + true + + + + 0 + 0 + + + + + 0 + 0 + + + + Artist Genre Settings + + + + + + Use Artist genres + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + false + + + + 0 + 0 + + + + Use Artist genres only if no Release Group genres exist + + + true + + + false + + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + false + + + + 0 + 0 + + + + Ignore Artist genres from these Artist(s): (comma separated regular expressions) + + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + false + + + + + + + + + + + + true + + + + 0 + 0 + + + + Work Genre Settings + + + + + + Use Work genres, when applicable + + + true + + + + + + + + + + + 0 + 0 + + + + + 0 + 120 + + + + General Settings + + + + + + Ignore these genres: (comma separated regular expressions) + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + + 0 + 0 + + + + + + + + + + false + + + + 0 + 0 + + + + Genre Delimiter: (only applicable to ID3v2.3 tags) + + + false + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 17 + + + + + + + + false + + + + 0 + 0 + + + + true + + + + / + + + + + ; + + + + + ; + + + + + ; + + + + + , + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + use_artist_genres + toggled(bool) + ignore_genres_from_these_artists_label + setEnabled(bool) + + + 300 + 58 + + + 313 + 111 + + + + + use_artist_genres + toggled(bool) + use_artist_only_if_no_release + setEnabled(bool) + + + 300 + 58 + + + 313 + 83 + + + + + use_artist_genres + toggled(bool) + ignore_genres_from_these_artists + setEnabled(bool) + + + 300 + 58 + + + 313 + 139 + + + + + diff --git a/plugins/wikidata/ui_options_wikidata.py b/plugins/wikidata/ui_options_wikidata.py new file mode 100644 index 00000000..3092c5c9 --- /dev/null +++ b/plugins/wikidata/ui_options_wikidata.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'options_wikidata.ui' +# +# Created by: PyQt5 UI code generator 5.11.3 +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore, QtGui, QtWidgets + +class Ui_WikidataOptionsPage(object): + def setupUi(self, WikidataOptionsPage): + WikidataOptionsPage.setObjectName("WikidataOptionsPage") + WikidataOptionsPage.resize(602, 512) + self.verticalLayout_2 = QtWidgets.QVBoxLayout(WikidataOptionsPage) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.releaseGroup_groupBox = QtWidgets.QGroupBox(WikidataOptionsPage) + self.releaseGroup_groupBox.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.releaseGroup_groupBox.sizePolicy().hasHeightForWidth()) + self.releaseGroup_groupBox.setSizePolicy(sizePolicy) + self.releaseGroup_groupBox.setMinimumSize(QtCore.QSize(0, 0)) + self.releaseGroup_groupBox.setObjectName("releaseGroup_groupBox") + self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.releaseGroup_groupBox) + self.verticalLayout_4.setObjectName("verticalLayout_4") + self.use_release_group_genres = QtWidgets.QCheckBox(self.releaseGroup_groupBox) + self.use_release_group_genres.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.use_release_group_genres.sizePolicy().hasHeightForWidth()) + self.use_release_group_genres.setSizePolicy(sizePolicy) + self.use_release_group_genres.setChecked(True) + self.use_release_group_genres.setObjectName("use_release_group_genres") + self.verticalLayout_4.addWidget(self.use_release_group_genres) + self.verticalLayout_2.addWidget(self.releaseGroup_groupBox) + self.artist_groupBox = QtWidgets.QGroupBox(WikidataOptionsPage) + self.artist_groupBox.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.artist_groupBox.sizePolicy().hasHeightForWidth()) + self.artist_groupBox.setSizePolicy(sizePolicy) + self.artist_groupBox.setMinimumSize(QtCore.QSize(0, 0)) + self.artist_groupBox.setObjectName("artist_groupBox") + self.verticalLayout_8 = QtWidgets.QVBoxLayout(self.artist_groupBox) + self.verticalLayout_8.setObjectName("verticalLayout_8") + self.use_artist_genres = QtWidgets.QCheckBox(self.artist_groupBox) + self.use_artist_genres.setObjectName("use_artist_genres") + self.verticalLayout_8.addWidget(self.use_artist_genres) + self.hLayout_use_artist_no_release = QtWidgets.QHBoxLayout() + self.hLayout_use_artist_no_release.setObjectName("hLayout_use_artist_no_release") + spacerItem = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.hLayout_use_artist_no_release.addItem(spacerItem) + self.use_artist_only_if_no_release = QtWidgets.QCheckBox(self.artist_groupBox) + self.use_artist_only_if_no_release.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.use_artist_only_if_no_release.sizePolicy().hasHeightForWidth()) + self.use_artist_only_if_no_release.setSizePolicy(sizePolicy) + self.use_artist_only_if_no_release.setCheckable(True) + self.use_artist_only_if_no_release.setChecked(False) + self.use_artist_only_if_no_release.setObjectName("use_artist_only_if_no_release") + self.hLayout_use_artist_no_release.addWidget(self.use_artist_only_if_no_release) + self.verticalLayout_8.addLayout(self.hLayout_use_artist_no_release) + self.hLayout_ignore_genres_from_artists_label = QtWidgets.QHBoxLayout() + self.hLayout_ignore_genres_from_artists_label.setObjectName("hLayout_ignore_genres_from_artists_label") + spacerItem1 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.hLayout_ignore_genres_from_artists_label.addItem(spacerItem1) + self.ignore_genres_from_these_artists_label = QtWidgets.QLabel(self.artist_groupBox) + self.ignore_genres_from_these_artists_label.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.ignore_genres_from_these_artists_label.sizePolicy().hasHeightForWidth()) + self.ignore_genres_from_these_artists_label.setSizePolicy(sizePolicy) + self.ignore_genres_from_these_artists_label.setObjectName("ignore_genres_from_these_artists_label") + self.hLayout_ignore_genres_from_artists_label.addWidget(self.ignore_genres_from_these_artists_label) + self.verticalLayout_8.addLayout(self.hLayout_ignore_genres_from_artists_label) + self.hLayout_ignore_genres_from_artists = QtWidgets.QHBoxLayout() + self.hLayout_ignore_genres_from_artists.setObjectName("hLayout_ignore_genres_from_artists") + spacerItem2 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.hLayout_ignore_genres_from_artists.addItem(spacerItem2) + self.ignore_genres_from_these_artists = QtWidgets.QLineEdit(self.artist_groupBox) + self.ignore_genres_from_these_artists.setEnabled(False) + self.ignore_genres_from_these_artists.setObjectName("ignore_genres_from_these_artists") + self.hLayout_ignore_genres_from_artists.addWidget(self.ignore_genres_from_these_artists) + self.verticalLayout_8.addLayout(self.hLayout_ignore_genres_from_artists) + self.verticalLayout_2.addWidget(self.artist_groupBox) + self.work_groupBox = QtWidgets.QGroupBox(WikidataOptionsPage) + self.work_groupBox.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.work_groupBox.sizePolicy().hasHeightForWidth()) + self.work_groupBox.setSizePolicy(sizePolicy) + self.work_groupBox.setObjectName("work_groupBox") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.work_groupBox) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.use_work_genres = QtWidgets.QCheckBox(self.work_groupBox) + self.use_work_genres.setChecked(True) + self.use_work_genres.setObjectName("use_work_genres") + self.verticalLayout_3.addWidget(self.use_work_genres) + self.verticalLayout_2.addWidget(self.work_groupBox) + self.generalSettings_groupBox = QtWidgets.QGroupBox(WikidataOptionsPage) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.generalSettings_groupBox.sizePolicy().hasHeightForWidth()) + self.generalSettings_groupBox.setSizePolicy(sizePolicy) + self.generalSettings_groupBox.setMinimumSize(QtCore.QSize(0, 120)) + self.generalSettings_groupBox.setObjectName("generalSettings_groupBox") + self.verticalLayout = QtWidgets.QVBoxLayout(self.generalSettings_groupBox) + self.verticalLayout.setObjectName("verticalLayout") + self.ignore_genres_label = QtWidgets.QLabel(self.generalSettings_groupBox) + self.ignore_genres_label.setObjectName("ignore_genres_label") + self.verticalLayout.addWidget(self.ignore_genres_label) + self.hLayout_ignore_these_genres = QtWidgets.QHBoxLayout() + self.hLayout_ignore_these_genres.setObjectName("hLayout_ignore_these_genres") + spacerItem3 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.hLayout_ignore_these_genres.addItem(spacerItem3) + self.ignore_these_genres = QtWidgets.QLineEdit(self.generalSettings_groupBox) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.ignore_these_genres.sizePolicy().hasHeightForWidth()) + self.ignore_these_genres.setSizePolicy(sizePolicy) + self.ignore_these_genres.setObjectName("ignore_these_genres") + self.hLayout_ignore_these_genres.addWidget(self.ignore_these_genres) + self.verticalLayout.addLayout(self.hLayout_ignore_these_genres) + self.genre_delimiter_label = QtWidgets.QLabel(self.generalSettings_groupBox) + self.genre_delimiter_label.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.genre_delimiter_label.sizePolicy().hasHeightForWidth()) + self.genre_delimiter_label.setSizePolicy(sizePolicy) + self.genre_delimiter_label.setWordWrap(False) + self.genre_delimiter_label.setObjectName("genre_delimiter_label") + self.verticalLayout.addWidget(self.genre_delimiter_label) + self.hLayout_genre_delimiter = QtWidgets.QHBoxLayout() + self.hLayout_genre_delimiter.setObjectName("hLayout_genre_delimiter") + spacerItem4 = QtWidgets.QSpacerItem(20, 17, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.hLayout_genre_delimiter.addItem(spacerItem4) + self.genre_delimiter = QtWidgets.QComboBox(self.generalSettings_groupBox) + self.genre_delimiter.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.genre_delimiter.sizePolicy().hasHeightForWidth()) + self.genre_delimiter.setSizePolicy(sizePolicy) + self.genre_delimiter.setEditable(True) + self.genre_delimiter.setObjectName("genre_delimiter") + self.genre_delimiter.addItem("") + self.genre_delimiter.setItemText(0, " / ") + self.genre_delimiter.addItem("") + self.genre_delimiter.addItem("") + self.genre_delimiter.setItemText(2, ";") + self.genre_delimiter.addItem("") + self.genre_delimiter.addItem("") + self.genre_delimiter.setItemText(4, ", ") + self.hLayout_genre_delimiter.addWidget(self.genre_delimiter) + spacerItem5 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.hLayout_genre_delimiter.addItem(spacerItem5) + self.verticalLayout.addLayout(self.hLayout_genre_delimiter) + self.verticalLayout_2.addWidget(self.generalSettings_groupBox) + spacerItem6 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_2.addItem(spacerItem6) + + self.retranslateUi(WikidataOptionsPage) + self.use_artist_genres.toggled['bool'].connect(self.ignore_genres_from_these_artists_label.setEnabled) + self.use_artist_genres.toggled['bool'].connect(self.use_artist_only_if_no_release.setEnabled) + self.use_artist_genres.toggled['bool'].connect(self.ignore_genres_from_these_artists.setEnabled) + QtCore.QMetaObject.connectSlotsByName(WikidataOptionsPage) + + def retranslateUi(self, WikidataOptionsPage): + _translate = QtCore.QCoreApplication.translate + self.releaseGroup_groupBox.setTitle(_translate("WikidataOptionsPage", "Release Group Genre Settings")) + self.use_release_group_genres.setText(_translate("WikidataOptionsPage", "Use Release Group genres")) + self.artist_groupBox.setTitle(_translate("WikidataOptionsPage", "Artist Genre Settings")) + self.use_artist_genres.setText(_translate("WikidataOptionsPage", "Use Artist genres")) + self.use_artist_only_if_no_release.setText(_translate("WikidataOptionsPage", "Use Artist genres only if no Release Group genres exist")) + self.ignore_genres_from_these_artists_label.setText(_translate("WikidataOptionsPage", "Ignore Artist genres from these Artist(s): (comma separated regular expressions)")) + self.work_groupBox.setTitle(_translate("WikidataOptionsPage", "Work Genre Settings")) + self.use_work_genres.setText(_translate("WikidataOptionsPage", "Use Work genres, when applicable")) + self.generalSettings_groupBox.setTitle(_translate("WikidataOptionsPage", "General Settings")) + self.ignore_genres_label.setText(_translate("WikidataOptionsPage", "Ignore these genres: (comma separated regular expressions)")) + self.genre_delimiter_label.setText(_translate("WikidataOptionsPage", "Genre Delimiter: (only applicable to ID3v2.3 tags)")) + self.genre_delimiter.setItemText(1, _translate("WikidataOptionsPage", "; ")) + self.genre_delimiter.setItemText(3, _translate("WikidataOptionsPage", " ; ")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + WikidataOptionsPage = QtWidgets.QWidget() + ui = Ui_WikidataOptionsPage() + ui.setupUi(WikidataOptionsPage) + WikidataOptionsPage.show() + sys.exit(app.exec_()) + diff --git a/plugins/wikidata/wikidata.py b/plugins/wikidata/wikidata.py deleted file mode 100644 index 4c8dec35..00000000 --- a/plugins/wikidata/wikidata.py +++ /dev/null @@ -1,219 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright © 2016 Daniel sobey - -# This work is free. You can redistribute it and/or modify it under the -# terms of the Do What The Fuck You Want To Public License, Version 2, -# as published by Sam Hocevar. See http://www.wtfpl.net/ for more details. - -PLUGIN_NAME = 'wikidata-genre' -PLUGIN_AUTHOR = 'Daniel Sobey, Sambhav Kothari' -PLUGIN_DESCRIPTION = 'query wikidata to get genre tags' -PLUGIN_VERSION = '1.0' -PLUGIN_API_VERSIONS = ["2.0"] -PLUGIN_LICENSE = 'WTFPL' -PLUGIN_LICENSE_URL = 'http://www.wtfpl.net/' - -from picard import config, log -from picard.metadata import register_track_metadata_processor -from functools import partial -import threading - - -class wikidata: - - def __init__(self): - self.lock = threading.Lock() - # active request queue - self.requests = {} - self.albums = {} - - # cache - self.cache = {} - - def process_release(self, album, metadata, release): - - self.ws = album.tagger.webservice - self.log = album.log - item_id = dict.get(metadata, 'musicbrainz_releasegroupid')[0] - - log.info('WIKIDATA: processing release group %s ' % item_id) - self.process_request(metadata, album, item_id, type='release-group') - for artist in dict.get(metadata, 'musicbrainz_albumartistid'): - item_id = artist - log.info('WIKIDATA: processing release artist %s' % item_id) - self.process_request(metadata, album, item_id, type='artist') - - def process_request(self, metadata, album, item_id, type): - with self.lock: - log.debug('WIKIDATA: Looking up cache for item %s' % item_id) - log.debug('WIKIDATA: requests %s' % album._requests) - log.debug('WIKIDATA: TYPE %s' % type) - if item_id in list(self.cache.keys()): - log.info('WIKIDATA: found in cache') - genre_list = self.cache.get(item_id) - new_genre = set(metadata.getall("genre")) - new_genre.update(genre_list) - metadata["genre"] = list(new_genre) - - album._finalize_loading(None) - return - else: - # pending requests are handled by adding the metadata object to a - # list of things to be updated when the genre is found - if item_id in list(self.requests.keys()): - log.debug( - 'WIKIDATA: request already pending, add it to the list of items to update once this has been found') - self.requests[item_id].append(metadata) - - album._requests += 1 - self.albums[item_id].append(album) - return - self.requests[item_id] = [metadata] - album._requests += 1 - self.albums[item_id] = [album] - log.debug('WIKIDATA: first request for this item') - - log.info('WIKIDATA: about to call musicbrainz to look up %s ' % item_id) - # find the wikidata url if this exists - host = config.setting["server_host"] - port = config.setting["server_port"] - - path = '/ws/2/%s/%s' % (type, item_id) - queryargs = {"inc": "url-rels"} - self.ws.get(host, port, path, - partial(self.musicbrainz_release_lookup, - item_id, metadata), - parse_response_type="xml", priority=False, - important=False, queryargs=queryargs) - - def musicbrainz_release_lookup(self, item_id, metadata, response, reply, error): - found = False - if error: - log.info('WIKIDATA: error retrieving release group info') - else: - if 'metadata' in response.children: - if 'release_group' in response.metadata[0].children: - if 'relation_list' in response.metadata[0].release_group[0].children: - for relation in response.metadata[0].release_group[0].relation_list[0].relation: - if relation.type == 'wikidata' and 'target' in relation.children: - found = True - wikidata_url = relation.target[0].text - self.process_wikidata(wikidata_url, item_id) - if 'artist' in response.metadata[0].children: - if 'relation_list' in response.metadata[0].artist[0].children: - for relation in response.metadata[0].artist[0].relation_list[0].relation: - if relation.type == 'wikidata' and 'target' in relation.children: - found = True - wikidata_url = relation.target[0].text - self.process_wikidata(wikidata_url, item_id) - - if 'work' in response.metadata[0].children: - if 'relation_list' in response.metadata[0].work[0].children: - for relation in response.metadata[0].work[0].relation_list[0].relation: - if relation.type == 'wikidata' and 'target' in relation.children: - found = True - wikidata_url = relation.target[0].text - self.process_wikidata(wikidata_url, item_id) - if not found: - log.info('WIKIDATA: no wikidata url') - with self.lock: - for album in self.albums[item_id]: - album._requests -= 1 - album._finalize_loading(None) - log.debug('WIKIDATA: TOTAL REMAINING REQUESTS %s' % - album._requests) - del self.requests[item_id] - - def process_wikidata(self, wikidata_url, item_id): - item = wikidata_url.split('/')[4] - path = "/wiki/Special:EntityData/" + item + ".rdf" - log.info('WIKIDATA: fetching the folowing url wikidata.org%s' % path) - self.ws.get('www.wikidata.org', 443, path, - partial(self.parse_wikidata_response, item, item_id), - parse_response_type="xml", priority=False, important=False) - - def parse_wikidata_response(self, item, item_id, response, reply, error): - genre_entries = [] - genre_list = [] - if error: - log.error('WIKIDATA: error getting data from wikidata.org') - else: - if 'RDF' in response.children: - node = response.RDF[0] - for node1 in node.Description: - if 'about' in node1.attribs: - if node1.attribs.get('about') == 'http://www.wikidata.org/entity/%s' % item: - for key, val in list(node1.children.items()): - if key == 'P136': - for i in val: - if 'resource' in i.attribs: - tmp = i.attribs.get('resource') - if 'entity' == tmp.split('/')[3] and len(tmp.split('/')) == 5: - genre_id = tmp.split('/')[4] - log.info( - 'WIKIDATA: Found the wikidata id for the genre: %s' % genre_id) - genre_entries.append(tmp) - else: - for tmp in genre_entries: - if tmp == node1.attribs.get('about'): - list1 = node1.children.get('name') - for node2 in list1: - if node2.attribs.get('lang') == 'en': - genre = node2.text.title() - genre_list.append(genre) - log.debug( - 'Our genre is: %s' % genre) - - with self.lock: - if len(genre_list) > 0: - log.info('WiKIDATA: final list of wikidata id found: %s' % - genre_entries) - log.info('WIKIDATA: final list of genre: %s' % genre_list) - - log.debug('WIKIDATA: total items to update: %s ' % - len(self.requests[item_id])) - for metadata in self.requests[item_id]: - new_genre = set(metadata.getall("genre")) - new_genre.update(genre_list) - metadata["genre"] = list(new_genre) - self.cache[item_id] = genre_list - log.debug('WIKIDATA: setting genre : %s ' % genre_list) - - else: - log.info('WIKIDATA: Genre not found in wikidata') - - log.info('WIKIDATA: Seeing if we can finalize tags %s ' % - len(self.albums[item_id])) - - for album in self.albums[item_id]: - album._requests -= 1 - album._finalize_loading(None) - log.info('WIKIDATA: TOTAL REMAINING REQUESTS %s' % - album._requests) - del self.requests[item_id] - - def process_track(self, album, metadata, trackXmlNode, releaseXmlNode): - self.ws = album.tagger.webservice - self.log = album.log - - for release_group in dict.get(metadata, 'musicbrainz_releasegroupid', []): - log.debug('WIKIDATA: looking up release group metadata for %s ' % release_group) - self.process_request(metadata, album, release_group, type='release-group') - - for artist in dict.get(metadata, 'musicbrainz_albumartistid', []): - log.info('WIKIDATA: processing release artist %s' % artist) - self.process_request(metadata, album, artist, type='artist') - - for artist in dict.get(metadata, 'musicbrainz_artistid'): - log.info('WIKIDATA: processing track artist %s' % artist) - self.process_request(metadata, album, artist, type='artist') - - if 'musicbrainz_workid' in metadata: - for workid in dict.get(metadata, 'musicbrainz_workid'): - log.info('WIKIDATA: processing track artist %s' % workid) - self.process_request(metadata, album, workid, type='work') - - -wikidata = wikidata() -# register_album_metadata_processor(wikidata.process_release) -register_track_metadata_processor(wikidata.process_track) diff --git a/plugins/workandmovement/__init__.py b/plugins/workandmovement/__init__.py new file mode 100644 index 00000000..6355f40d --- /dev/null +++ b/plugins/workandmovement/__init__.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018-2019 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. + +PLUGIN_NAME = 'Work & Movement' +PLUGIN_AUTHOR = 'Philipp Wolfer' +PLUGIN_DESCRIPTION = 'Set work and movement based on work relationships' +PLUGIN_VERSION = '1.0.1' +PLUGIN_API_VERSIONS = ['2.1', '2.2'] +PLUGIN_LICENSE = 'GPL-2.0-or-later' +PLUGIN_LICENSE_URL = 'https://www.gnu.org/licenses/gpl-2.0.html' + + +import re + +from .roman import ( + fromRoman, + RomanError, +) + +from picard import log +from picard.metadata import register_track_metadata_processor + + +_re_work_title = re.compile(r'(?P.*):\s+(?P[IVXLCDM]+)\.\s+(?P.*)') +_re_part_number = re.compile(r'(?P[0-9IVXLCDM]+)\.?\s+') + + +class Work: + def __init__(self, title, mbid=None): + self.mbid = mbid + self.title = title + self.is_movement = False + self.is_work = False + self.part_number = 0 + self.parent = None + + def __str__(self): + s = [] + if self.parent: + s.append(str(self.parent)) + if self.is_movement: + work_type = 'Movement' + elif self.is_work: + work_type = 'Work' + else: + work_type = 'Unknown' + s.append('%s %i: %s' % (work_type, self.part_number, self.title)) + return '\n'.join(s) + + +def is_performance_work(rel): + return (rel['target-type'] == 'work' + and rel['direction'] == 'forward' + and rel['type'] == 'performance') + + +def is_parent_work(rel): + return (rel['target-type'] == 'work' + and rel['direction'] == 'backward' + and rel['type'] == 'parts') + + +def is_movement_like(rel): + return ('movement' in rel['attributes'] + or 'act' in rel['attributes'] + or 'ordering-key' in rel) + + +def is_child_work(rel): + return (rel['target-type'] == 'work' + and rel['direction'] == 'forward' + and rel['type'] == 'parts') + + +def number_to_int(s): + """ + Converts a numeric string to int. `s` can also be a Roman numeral. + """ + try: + return int(s) + except ValueError: + try: + return fromRoman(s) + except RomanError as e: + raise ValueError(e) + + +def parse_work_name(title): + return _re_work_title.search(title) + + +def create_work_and_movement_from_title(work): + """ + Attempts to parse work.title in the form ": . ", + where is in Roman numerals. + Sets the `is_movement` and `part_number` properties on `work` and creates + a `parent` work if not already present. + """ + title = work.title + match = parse_work_name(title) + if match: + work.title = match.group('movement') + work.is_movement = True + try: + number = number_to_int(match.group('movementnumber')) + except ValueError as e: + log.error(e) + number = 0 + if not work.part_number: + work.part_number = number + elif work.part_number != number: + log.warning('Movement number mismatch for "%s": %s != %i', + title, match.group('movementnumber'), work.part_number) + if not work.parent: + work.parent = Work(match.group('work')) + work.parent.is_work = True + elif work.parent.title != match.group('work'): + log.warning('Movement work name mismatch for "%s": "%s" != "%s"', + title, match.group('work'), work.parent.title) + return work + + +def normalize_movement_title(work): + """ + Removes the parent work title and part number from the beginning of + `work.title`. This ensures movement names don't contain duplicated + information even if they do not follow the strict naming format used by + `create_work_and_movement_from_title`. + """ + movement_title = work.title + if work.parent: + work_title = work.parent.title + if movement_title.startswith(work_title): + movement_title = movement_title[len(work_title):].lstrip(':').strip() + match = _re_part_number.match(movement_title) + if match: + # Only remove the number if it matches the part_number + try: + number = number_to_int(match.group('number')) + if number == work.part_number: + movement_title = _re_part_number.sub("", movement_title) + except ValueError as e: + log.warning(e) + return movement_title + + +def parse_work(work_rel): + work = Work(work_rel['title'], work_rel['id']) + if 'relations' in work_rel: + for rel in work_rel['relations']: + # If this work has parents and is linked to those as 'movement' or + # 'act' we consider it a part of a larger work and store it + # in the movement tag. The parent will be set as the work. + if is_parent_work(rel): + if is_movement_like(rel): + work.is_movement = True + work.part_number = rel['ordering-key'] + if 'work' in rel: + work.parent = parse_work(rel['work']) + work.parent.is_work = True + work.title = normalize_movement_title(work) + else: + # Not a movement, but still part of a larger work. + # Mark it as a work. + work.is_work = True + # If this work has any parts, we consider it a proper work. + # This is a recording directly linked to a larger work. + if is_child_work(rel): + work.is_work = True + return work + + +def unset_work(metadata): + metadata.delete('work') + metadata.delete('musicbrainz_workid') + metadata.delete('movement') + metadata.delete('movementnumber') + metadata.delete('movementtotal') + metadata.delete('showmovement') + + +def set_work(metadata, work): + metadata['work'] = work.title + metadata['musicbrainz_workid'] = work.mbid + metadata['showmovement'] = 1 + + +def process_track(album, metadata, track, release): + if 'recording' in track: + recording = track['recording'] + else: + recording = track + + if 'relations' not in recording: + return + + work = Work(recording['title']) + for rel in recording['relations']: + if is_performance_work(rel): + work = parse_work(rel['work']) + # Only use the first work that qualifies as a work or movement + log.debug('Found work:\n%s', work) + if work.is_movement or work.is_work: + break + + unset_work(metadata) + if not work.is_movement: + work = create_work_and_movement_from_title(work) + + if work.is_movement and work.parent and work.parent.is_work: + metadata['movement'] = work.title + if work.part_number: + metadata['movementnumber'] = work.part_number + set_work(metadata, work.parent) + elif work.is_work: + set_work(metadata, work) + + +register_track_metadata_processor(process_track) diff --git a/plugins/workandmovement/roman.py b/plugins/workandmovement/roman.py new file mode 100644 index 00000000..944b7263 --- /dev/null +++ b/plugins/workandmovement/roman.py @@ -0,0 +1,80 @@ +"""Convert to and from Roman numerals""" + +__author__ = "Mark Pilgrim (f8dy@diveintopython.org)" +__version__ = "1.4" +__date__ = "8 August 2001" +__copyright__ = """Copyright (c) 2001 Mark Pilgrim + +This program is part of "Dive Into Python", a free Python tutorial for +experienced programmers. Visit http://diveintopython.org/ for the +latest version. + +This program is free software; you can redistribute it and/or modify +it under the terms of the Python 2.1.1 license, available at +http://www.python.org/2.1.1/license.html +""" + +import re + +#Define exceptions +class RomanError(Exception): pass +class OutOfRangeError(RomanError): pass +class NotIntegerError(RomanError): pass +class InvalidRomanNumeralError(RomanError): pass + +#Define digit mapping +romanNumeralMap = (('M', 1000), + ('CM', 900), + ('D', 500), + ('CD', 400), + ('C', 100), + ('XC', 90), + ('L', 50), + ('XL', 40), + ('X', 10), + ('IX', 9), + ('V', 5), + ('IV', 4), + ('I', 1)) + +def toRoman(n): + """convert integer to Roman numeral""" + if not isinstance(n, int): + raise NotIntegerError("decimals can not be converted") + if not (0 < n < 5000): + raise OutOfRangeError("number out of range (must be 1..4999)") + + result = "" + for numeral, integer in romanNumeralMap: + while n >= integer: + result += numeral + n -= integer + return result + +#Define pattern to detect valid Roman numerals +romanNumeralPattern = re.compile(""" + ^ # beginning of string + M{0,4} # thousands - 0 to 4 M's + (CM|CD|D?C{0,3}) # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 C's), + # or 500-800 (D, followed by 0 to 3 C's) + (XC|XL|L?X{0,3}) # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 X's), + # or 50-80 (L, followed by 0 to 3 X's) + (IX|IV|V?I{0,3}) # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 I's), + # or 5-8 (V, followed by 0 to 3 I's) + $ # end of string + """ ,re.VERBOSE) + +def fromRoman(s): + """convert Roman numeral to integer""" + if not s: + raise InvalidRomanNumeralError('Input can not be blank') + if not romanNumeralPattern.search(s): + raise InvalidRomanNumeralError('Invalid Roman numeral: %s' % s) + + result = 0 + index = 0 + for numeral, integer in romanNumeralMap: + while s[index:index+len(numeral)] == numeral: + result += integer + index += len(numeral) + return result diff --git a/test.py b/test.py index 1384ef35..272343ed 100644 --- a/test.py +++ b/test.py @@ -1,3 +1,4 @@ +import doctest import os import glob import json @@ -6,12 +7,6 @@ import unittest from generate import build_json, zip_files -# python 2 & 3 compatibility -try: - basestring -except NameError: - basestring = str - class GenerateTestCase(unittest.TestCase): @@ -86,11 +81,17 @@ def test_valid_json(self): # All plugins should contain all required fields for module_name, data in plugin_json.items(): - self.assertIsInstance(data['name'], basestring) + self.assertIsInstance(data['name'], str) self.assertIsInstance(data['api_versions'], list) - self.assertIsInstance(data['author'], basestring) - self.assertIsInstance(data['description'], basestring) - self.assertIsInstance(data['version'], basestring) + self.assertIsInstance(data['author'], str) + self.assertIsInstance(data['description'], str) + self.assertIsInstance(data['version'], str) + + +def load_tests(loader, tests, ignore): + from plugins.addrelease import addrelease + tests.addTests(doctest.DocTestSuite(addrelease)) + return tests if __name__ == '__main__':