├── .gitignore ├── .travis.yml ├── README.md ├── generate.py ├── get_plugin_data.py ├── plugins ├── abbreviate_artistsort │ └── abbreviate_artistsort.py ├── abbreviate_artistsort2 │ └── abbreviate_artistsort2.py ├── acousticbrainz │ └── acousticbrainz.py ├── acousticbrainz_tonal-rhythm │ └── acousticbrainz_tonal-rhythm.py ├── add_album_column │ └── __init__.py ├── addrelease │ └── addrelease.py ├── albumartist_website │ └── albumartist_website.py ├── albumartistextension │ └── albumartistextension.py ├── bpm │ ├── __init__.py │ ├── options_bpm.ui │ └── ui_options_bpm.py ├── classical_extras │ ├── Readme.md │ ├── __init__.py │ ├── options_classical_extras.ui │ ├── suffixtree.py │ └── ui_options_classical_extras.py ├── classical_work_collection │ ├── .idea │ │ └── classical_work_collection.iml │ ├── __init__.py │ ├── confirm.ui │ ├── readme.md │ ├── select_collections.ui │ ├── ui_confirm.py │ ├── ui_select_collections.py │ └── workscollection.py ├── classicdiscnumber │ └── classicdiscnumber.py ├── cuesheet │ └── cuesheet.py ├── decode_cyrillic │ └── decode_cyrillic.py ├── discnumber │ └── discnumber.py ├── fanarttv │ ├── __init__.py │ ├── options_fanarttv.ui │ └── ui_options_fanarttv.py ├── featartist │ └── featartist.py ├── featartistsintitles │ └── featartistsintitles.py ├── keep │ └── keep.py ├── lastfm │ ├── __init__.py │ ├── options_lastfm.ui │ └── ui_options_lastfm.py ├── lastfmplus │ ├── __init__.py │ └── ui_options_lastfm.py ├── loadasnat │ └── loadasnat.py ├── moodbars │ ├── __init__.py │ ├── options_moodbar.ui │ └── ui_options_moodbar.py ├── musixmatch │ ├── README │ ├── __init__.py │ ├── musixmatch │ │ ├── __init__.py │ │ ├── track.py │ │ └── util.py │ └── ui_options_musixmatch.py ├── no_release │ └── no_release.py ├── non_ascii_equivalents │ └── non_ascii_equivalents.py ├── padded │ └── padded.py ├── papercdcase │ └── papercdcase.py ├── playlist │ └── playlist.py ├── release_type │ └── release_type.py ├── remove_perfect_albums │ └── remove_perfect_albums.py ├── reorder_sides │ └── reorder_sides.py ├── replaygain │ ├── __init__.py │ ├── options_replaygain.ui │ └── ui_options_replaygain.py ├── save_and_rewrite_header │ └── save_and_rewrite_header.py ├── smart_title_case │ └── smart_title_case.py ├── sort_multivalue_tags │ └── sort_multivalue_tags.py ├── soundtrack │ └── soundtrack.py ├── standardise_feat │ └── standardise_feat.py ├── standardise_performers │ └── standardise_performers.py ├── tangoinfo │ └── tangoinfo.py ├── titlecase │ └── titlecase.py ├── tracks2clipboard │ └── tracks2clipboard.py ├── videotools │ ├── __init__.py │ ├── enzyme │ │ ├── __init__.py │ │ ├── asf.py │ │ ├── core.py │ │ ├── exceptions.py │ │ ├── flv.py │ │ ├── fourcc.py │ │ ├── infos.py │ │ ├── language.py │ │ ├── mkv.py │ │ ├── mp4.py │ │ ├── mpeg.py │ │ ├── ogm.py │ │ ├── real.py │ │ ├── riff.py │ │ └── strutils.py │ ├── formats.py │ └── script.py ├── viewvariables │ ├── __init__.py │ ├── ui_variables_dialog.py │ └── variables_dialog.ui └── wikidata │ └── wikidata.py └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by the script 2 | plugins.json 3 | plugins/*.zip 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | script: python test.py 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MusicBrainz Picard Plugins 2 | 3 | This repository hosts plugins for [MusicBrainz Picard](https://picard.musicbrainz.org/). If you're a plugin author and would like to include your plugin here, simply open a pull request. 4 | 5 | Note that new plugins being added to the repository should be under the GNU General Public License version 2 ("GPL") or a license compatible with it. See https://www.gnu.org/licenses/license-list.html for a list of compatible licenses. 6 | 7 | ## Development Notes 8 | 9 | The script `generate.py` will generate a file called `plugins.json`, which contains metadata about all the plugins in this repository. `plugins.json` is used by [picard-website](https://github.com/musicbrainz/picard-website) and Picard itself to display information about downloadable plugins. 10 | -------------------------------------------------------------------------------- /generate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | import os 5 | import re 6 | import sys 7 | import json 8 | 9 | import zipfile 10 | import zlib 11 | 12 | from hashlib import md5 13 | from subprocess import call 14 | 15 | from get_plugin_data import get_plugin_data 16 | 17 | 18 | def build_json(): 19 | """ 20 | Traverse the plugins directory to generate json data. 21 | """ 22 | 23 | plugins = {} 24 | 25 | # All top level directories in plugin_dir are plugins 26 | for dirname in next(os.walk(plugin_dir))[1]: 27 | 28 | files = {} 29 | data = {} 30 | 31 | if dirname in [".git"]: 32 | continue 33 | 34 | dirpath = os.path.join(plugin_dir, dirname) 35 | for root, dirs, filenames in os.walk(dirpath): 36 | for filename in filenames: 37 | ext = os.path.splitext(filename)[1] 38 | 39 | if ext not in [".pyc"]: 40 | file_path = os.path.join(root, filename) 41 | with open(file_path, "rb") as md5file: 42 | md5Hash = md5(md5file.read()).hexdigest() 43 | files[file_path.split(os.path.join(dirpath, ''))[1]] = md5Hash 44 | 45 | if ext in ['.py'] and not data: 46 | data = get_plugin_data(os.path.join(plugin_dir, dirname, filename)) 47 | 48 | if files and data: 49 | print("Added: " + dirname) 50 | data['files'] = files 51 | plugins[dirname] = data 52 | 53 | with open(plugin_file, "w") as out_file: 54 | json.dump({"plugins": plugins}, out_file, sort_keys=True, indent=2) 55 | 56 | 57 | def zip_files(): 58 | """ 59 | Zip up plugin folders 60 | """ 61 | 62 | for dirname in next(os.walk(plugin_dir))[1]: 63 | archive_path = os.path.join(plugin_dir, dirname) 64 | archive = zipfile.ZipFile(archive_path + ".zip", "w") 65 | 66 | dirpath = os.path.join(plugin_dir, dirname) 67 | plugin_files = [] 68 | 69 | for root, dirs, filenames in os.walk(dirpath): 70 | for filename in filenames: 71 | file_path = os.path.join(root, filename) 72 | plugin_files.append(file_path) 73 | 74 | if len(plugin_files) == 1: 75 | # There's only one file, put it directly into the zipfile 76 | archive.write(plugin_files[0], 77 | os.path.basename(plugin_files[0]), 78 | compress_type=zipfile.ZIP_DEFLATED) 79 | else: 80 | for filename in plugin_files: 81 | # Preserve the folder structure relative to plugin_dir 82 | # in the zip file 83 | name_in_zip = os.path.join(os.path.relpath(filename, 84 | plugin_dir)) 85 | archive.write(filename, 86 | name_in_zip, 87 | compress_type=zipfile.ZIP_DEFLATED) 88 | 89 | print("Created: " + dirname + ".zip") 90 | 91 | 92 | # The file that contains json data 93 | plugin_file = "plugins.json" 94 | 95 | # The directory which contains plugin files 96 | plugin_dir = "plugins" 97 | 98 | if __name__ == '__main__': 99 | if 1 in sys.argv: 100 | if sys.argv[1] == "pull": 101 | call(["git", "pull", "-q"]) 102 | elif sys.argv[1] == "json": 103 | build_json() 104 | elif sys.argv[1] == "zip": 105 | zip_files() 106 | else: 107 | # call(["git", "pull", "-q"]) 108 | build_json() 109 | zip_files() 110 | -------------------------------------------------------------------------------- /get_plugin_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | import ast 5 | 6 | KNOWN_DATA = [ 7 | 'PLUGIN_NAME', 8 | 'PLUGIN_AUTHOR', 9 | 'PLUGIN_VERSION', 10 | 'PLUGIN_API_VERSIONS', 11 | 'PLUGIN_LICENSE', 12 | 'PLUGIN_LICENSE_URL', 13 | 'PLUGIN_DESCRIPTION', 14 | ] 15 | 16 | 17 | def get_plugin_data(filepath): 18 | """Parse a python file and return a dict with plugin metadata""" 19 | data = {} 20 | with open(filepath, 'rU') as plugin_file: 21 | source = plugin_file.read() 22 | try: 23 | root = ast.parse(source, filepath) 24 | except: 25 | print("Cannot parse " + filepath) 26 | raise 27 | for node in ast.iter_child_nodes(root): 28 | if isinstance(node, ast.Assign) and len(node.targets) == 1: 29 | target = node.targets[0] 30 | if (isinstance(target, ast.Name) 31 | and isinstance(target.ctx, ast.Store) 32 | and target.id in KNOWN_DATA): 33 | name = target.id.replace('PLUGIN_', '', 1).lower() 34 | if name not in data: 35 | try: 36 | data[name] = ast.literal_eval(node.value) 37 | except ValueError: 38 | print('Cannot evaluate value in ' 39 | + filepath + ':' + 40 | ast.dump(node)) 41 | return data 42 | -------------------------------------------------------------------------------- /plugins/abbreviate_artistsort2/abbreviate_artistsort2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | 4 | # This is the Abbreviate Artist plugin for MusicBrainz Picard. 5 | # Copyright (C) 2013-2017 Sophist 6 | # 7 | # This program is free software; you can redistribute it and/or 8 | # modify it under the terms of the GNU General Public License 9 | # as published by the Free Software Foundation; either version 2 10 | # of the License, or (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | 17 | PLUGIN_NAME = u"Abbreviate Artist-Sort v2" 18 | PLUGIN_AUTHOR = u"Sophist" 19 | PLUGIN_DESCRIPTION = u''' 20 | Abbreviate Artist-Sort and Album-Artist-Sort Tags. 21 | e.g. "Vivaldi, Antonio" becomes "Vivaldi A" 22 | This is particularly useful for classical albums that can have a long list of artists. 23 | This version of the plugin differs from version 1 as it modifies the relevant metadata 24 | in place (rather than copying it into new variables. 25 | ''' 26 | PLUGIN_VERSION = "1.0" 27 | PLUGIN_API_VERSIONS = ["1.4"] 28 | PLUGIN_LICENSE = "GPL-2.0" 29 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 30 | 31 | import re, unicodedata 32 | 33 | artist_tags = [ 34 | ('artistsort', '~artists_sort', '~unabbrev_artistsort', '~unabbrev_artists_sort'), 35 | ('albumartistsort', '~albumartists_sort', '~unabbrev_albumartistsort', '~unabbrev_albumartists_sort'), 36 | ] 37 | 38 | def abbreviate_name(artist): 39 | if u"," not in artist: 40 | return artist 41 | surname, forenames = artist.split(u",", 1) 42 | return surname + u" " + "".join([x[0] for x in re.split(ur"\W+", forenames.strip())]) 43 | 44 | def string_cleanup(string, locale="utf-8"): 45 | if not string: 46 | return u"" 47 | if not isinstance(string, unicode): 48 | string = string.decode(locale) 49 | # Replace with normalised unicode string 50 | return unicodedata.normalize("NFKC", string) 51 | 52 | def abbreviate_artist(artist, locale="utf-8"): 53 | """Title-case a string using a less destructive method than str.title.""" 54 | return abbreviate_name(string_cleanup(artist, locale)) 55 | 56 | assert "Bach JS" == abbreviate_artist("Bach, Johann Sebastian") 57 | assert "The Beatles" == abbreviate_artist("The Beatles") 58 | 59 | def abbreviate_artistsort(text, artists, abbrev_artists): 60 | """ 61 | Use the array of artists and the joined string 62 | to identify artists to make title case 63 | and the join strings to leave as-is. 64 | """ 65 | find = u"^(" + ur")(\s*\S+\s*)(".join((map(re.escape, map(string_cleanup,artists)))) + u")(.*$)" 66 | replace = "".join([ur"%s\%d" % (a, x*2 + 2) for x, a in enumerate(abbrev_artists)]) 67 | result = re.sub(find, replace, string_cleanup(text), re.UNICODE) 68 | return result 69 | 70 | assert "Bach JS; London Symphony Orchestra" == abbreviate_artistsort( 71 | "Bach, Johann Sebastian; London Symphony Orchestra", 72 | ["Bach, Johann Sebastian", "London Symphony Orchestra"], 73 | ["Bach JS", "London Symphony Orchestra"], 74 | ) 75 | 76 | # Put this here so that above unit tests can run standalone before getting an import error 77 | from picard import log 78 | from picard.metadata import ( 79 | register_track_metadata_processor, 80 | register_album_metadata_processor, 81 | ) 82 | 83 | def abbrev_artistsort_metadata(tagger, metadata, release, track=None): 84 | for artist_string, artists_list, original_artist, original_artists in artist_tags: 85 | if artist_string in metadata and artists_list in metadata: 86 | artists = metadata.getall(artists_list) 87 | artist = metadata.getall(artist_string) 88 | abbrev_artists = map(abbreviate_artist, artists) 89 | abbrev_artist = [abbreviate_artistsort(x, artists, abbrev_artists) for x in artist] 90 | if artists != abbrev_artists and artist != abbrev_artist: 91 | log.debug("AbbrevArtistSort2: Abbreviated %s from %r to %r", artists_list, artists, abbrev_artists) 92 | metadata[original_artists] = artists 93 | metadata[artists_list] = abbrev_artists 94 | log.debug("AbbrevArtistSort2: Abbreviated %s from %r to %r", artist_string, artist, abbrev_artist) 95 | metadata[original_artist] = artist 96 | metadata[artist_string] = abbrev_artist 97 | elif artists != abbrev_artists or artist != abbrev_artist: 98 | if artists != abbrev_artists: 99 | log.warning("AbbrevArtistSort2: %s abbreviated, %s wasn't", artists_list, artist_string) 100 | else: 101 | log.warning("AbbrevArtistSort2: %s abbreviated, %s wasn't", artist_string, artists_list) 102 | 103 | register_track_metadata_processor(abbrev_artistsort_metadata) 104 | register_album_metadata_processor(abbrev_artistsort_metadata) 105 | -------------------------------------------------------------------------------- /plugins/acousticbrainz/acousticbrainz.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Acousticbrainz plugin for Picard 3 | # Copyright (C) 2015 Andrew Cook 4 | # 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License version 2 as 7 | # published by the Free Software Foundation. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License along 15 | # with this program; if not, write to the Free Software Foundation, Inc., 16 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 17 | # 18 | 19 | PLUGIN_NAME = u'AcousticBrainz Mood-Genre' 20 | PLUGIN_AUTHOR = u'Andrew Cook' 21 | PLUGIN_DESCRIPTION = u'''Uses AcousticBrainz for mood and genre. 22 | 23 | WARNING: Experimental plugin. All guarantees voided by use.''' 24 | PLUGIN_LICENSE = "GPL-2.0" 25 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt" 26 | PLUGIN_VERSION = "0.0" 27 | PLUGIN_API_VERSIONS = ["0.15"] 28 | 29 | from json import loads 30 | from functools import partial 31 | from picard import log 32 | from picard.metadata import register_track_metadata_processor 33 | from picard.webservice import REQUEST_DELAY 34 | 35 | ACOUSTICBRAINZ_HOST = "acousticbrainz.org" 36 | ACOUSTICBRAINZ_PORT = 80 37 | 38 | REQUEST_DELAY[(ACOUSTICBRAINZ_HOST, ACOUSTICBRAINZ_PORT)] = 50 39 | 40 | def result(album, metadata, data, reply, error): 41 | moods = [] 42 | genres = [] 43 | try: 44 | data = loads(data)["highlevel"] 45 | for k, v in data.items(): 46 | if k.startswith("genre_") and not v["value"].startswith("not_"): 47 | genres.append(v["value"]) 48 | if k.startswith("mood_") and not v["value"].startswith("not_"): 49 | moods.append(v["value"]) 50 | 51 | metadata["genre"] = genres 52 | metadata["mood"] = moods 53 | log.debug(u"%s: Track %s (%s) Parsed response (genres: %s, moods: %s)", PLUGIN_NAME, metadata["musicbrainz_recordingid"], metadata["title"], str(genres), str(moods)) 54 | except Exception as e: 55 | log.error(u"%s: Track %s (%s) Error parsing response: %s", PLUGIN_NAME, metadata["musicbrainz_recordingid"], metadata["title"], str(e)) 56 | finally: 57 | album._requests -= 1 58 | album._finalize_loading(None) 59 | 60 | def process_track(album, metadata, release, track): 61 | album.tagger.xmlws.download( 62 | ACOUSTICBRAINZ_HOST, 63 | ACOUSTICBRAINZ_PORT, 64 | u"/%s/high-level" % (metadata["musicbrainz_recordingid"]), 65 | partial(result, album, metadata), 66 | priority=True 67 | ) 68 | album._requests += 1 69 | 70 | register_track_metadata_processor(process_track) 71 | -------------------------------------------------------------------------------- /plugins/acousticbrainz_tonal-rhythm/acousticbrainz_tonal-rhythm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Acousticbrainz Tonal/Rhythm plugin for Picard 3 | # Copyright (C) 2015 Sophist 4 | # 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License version 2 as 7 | # published by the Free Software Foundation. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License along 15 | # with this program; if not, write to the Free Software Foundation, Inc., 16 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 17 | # 18 | 19 | PLUGIN_NAME = u'AcousticBrainz Tonal-Rhythm' 20 | PLUGIN_AUTHOR = u'Sophist' 21 | PLUGIN_DESCRIPTION = u'''Add's the following tags: 22 | 26 | from the AcousticBrainz database.

27 | Note: This plugin requires Picard 1.4.''' 28 | PLUGIN_LICENSE = "GPL-2.0" 29 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt" 30 | PLUGIN_VERSION = '0.1' 31 | PLUGIN_API_VERSIONS = ["1.4.0"] # Requires support for TKEY which is in 1.4 32 | 33 | import json 34 | from picard import config, log 35 | from picard.util import LockableObject 36 | from picard.metadata import register_track_metadata_processor 37 | from functools import partial 38 | from picard.webservice import REQUEST_DELAY 39 | 40 | ACOUSTICBRAINZ_HOST = "acousticbrainz.org" 41 | ACOUSTICBRAINZ_PORT = 80 42 | REQUEST_DELAY[(ACOUSTICBRAINZ_HOST, ACOUSTICBRAINZ_PORT)] = 50 43 | 44 | class AcousticBrainz_Key: 45 | 46 | def get_data(self, album, track_metadata, trackXmlNode, releaseXmlNode): 47 | recordingId = track_metadata['musicbrainz_recordingid'] 48 | if recordingId: 49 | log.debug("%s: Add AcusticBrainz request for %s (%s)", PLUGIN_NAME, track_metadata['title'], recordingId) 50 | self.album_add_request(album) 51 | path = "/%s/low-level" % recordingId 52 | return album.tagger.xmlws.get( 53 | ACOUSTICBRAINZ_HOST, 54 | ACOUSTICBRAINZ_PORT, 55 | path, 56 | partial(self.process_data, album, track_metadata), 57 | xml=False, priority=True, important=False) 58 | return 59 | 60 | def process_data(self, album, track_metadata, response, reply, error): 61 | if error: 62 | log.error("%s: Network error retrieving acousticBrainz data for recordingId %s", 63 | PLUGIN_NAME, track_metadata['musicbrainz_recordingid']) 64 | self.album_remove_request(album) 65 | return 66 | data = json.loads(response) 67 | if "tonal" in data: 68 | if "key_key" in data["tonal"]: 69 | key = data["tonal"]["key_key"] 70 | if "key_scale" in data["tonal"]: 71 | scale = data["tonal"]["key_scale"] 72 | if scale == "minor": 73 | key += "m" 74 | track_metadata["key"] = key 75 | log.debug("%s: Track '%s' is in key %s", PLUGIN_NAME, track_metadata["title"], key) 76 | if "rhythm" in data: 77 | if "bpm" in data["rhythm"]: 78 | bpm = int(data["rhythm"]["bpm"] + 0.5) 79 | track_metadata["bpm"] = bpm 80 | log.debug("%s: Track '%s' has %s bpm", PLUGIN_NAME, track_metadata["title"], bpm) 81 | self.album_remove_request(album) 82 | 83 | def album_add_request(self, album): 84 | album._requests += 1 85 | 86 | def album_remove_request(self, album): 87 | album._requests -= 1 88 | if album._requests == 0: 89 | album._finalize_loading(None) 90 | 91 | register_track_metadata_processor(AcousticBrainz_Key().get_data) 92 | -------------------------------------------------------------------------------- /plugins/add_album_column/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | # 4 | # Licensing 5 | # 6 | # Add Album Column, Add the Album column to the main window panel 7 | # Copyright (C) 2017 Evandro Coan 8 | # 9 | # This program is free software; you can redistribute it and/or modify it 10 | # under the terms of the GNU General Public License as published by the 11 | # Free Software Foundation; either version 3 of the License, or ( at 12 | # your option ) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, but 15 | # WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 17 | # General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program. If not, see . 21 | # 22 | 23 | PLUGIN_NAME = u"Add Album Column" 24 | PLUGIN_AUTHOR = u"Evandro Coan" 25 | PLUGIN_DESCRIPTION = "Add the Album column to the main window panel." 26 | 27 | PLUGIN_VERSION = "1.0" 28 | PLUGIN_API_VERSIONS = ["1.4.0"] 29 | PLUGIN_LICENSE = "GPLv3" 30 | PLUGIN_LICENSE_URL = "http://www.gnu.org/licenses/" 31 | 32 | from picard.ui.itemviews import MainPanel 33 | MainPanel.columns.append((N_('Album'), 'album')) 34 | -------------------------------------------------------------------------------- /plugins/albumartist_website/albumartist_website.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | PLUGIN_NAME = u'Album Artist Website' 4 | PLUGIN_AUTHOR = u'Sophist' 5 | PLUGIN_DESCRIPTION = u'''Add's the album artist(s) Official Homepage(s) 6 | (if they are defined in the MusicBrainz database).''' 7 | PLUGIN_VERSION = '0.6' 8 | PLUGIN_API_VERSIONS = ["1.4.0"] 9 | PLUGIN_LICENSE = "GPL-2.0" 10 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 11 | 12 | from picard import config, log 13 | from picard.util import LockableObject 14 | from picard.metadata import register_track_metadata_processor 15 | from functools import partial 16 | 17 | 18 | class AlbumArtistWebsite: 19 | 20 | class ArtistWebsiteQueue(LockableObject): 21 | 22 | def __init__(self): 23 | LockableObject.__init__(self) 24 | self.queue = {} 25 | 26 | def __contains__(self, name): 27 | return name in self.queue 28 | 29 | def __iter__(self): 30 | return self.queue.__iter__() 31 | 32 | def __getitem__(self, name): 33 | self.lock_for_read() 34 | value = self.queue[name] if name in self.queue else None 35 | self.unlock() 36 | return value 37 | 38 | def __setitem__(self, name, value): 39 | self.lock_for_write() 40 | self.queue[name] = value 41 | self.unlock() 42 | 43 | def append(self, name, value): 44 | self.lock_for_write() 45 | if name in self.queue: 46 | self.queue[name].append(value) 47 | value = False 48 | else: 49 | self.queue[name] = [value] 50 | value = True 51 | self.unlock() 52 | return value 53 | 54 | def remove(self, name): 55 | self.lock_for_write() 56 | value = None 57 | if name in self.queue: 58 | value = self.queue[name] 59 | del self.queue[name] 60 | self.unlock() 61 | return value 62 | 63 | def __init__(self): 64 | self.website_cache = {} 65 | self.website_queue = self.ArtistWebsiteQueue() 66 | 67 | def add_artist_website(self, album, track_metadata, trackXmlNode, releaseXmlNode): 68 | albumArtistIds = dict.get(track_metadata,'musicbrainz_albumartistid', []) 69 | for artistId in albumArtistIds: 70 | if artistId in self.website_cache: 71 | if self.website_cache[artistId]: 72 | track_metadata['website'] = self.website_cache[artistId] 73 | else: 74 | # Jump through hoops to get track object!! 75 | self.website_add_track(album, album._new_tracks[-1], artistId) 76 | 77 | def website_add_track(self, album, track, artistId): 78 | self.album_add_request(album) 79 | if self.website_queue.append(artistId, (track, album)): 80 | host = config.setting["server_host"] 81 | port = config.setting["server_port"] 82 | path = "/ws/2/%s/%s" % ('artist', artistId) 83 | queryargs = {"inc": "url-rels"} 84 | return album.tagger.xmlws.get(host, port, path, 85 | partial(self.website_process, artistId), 86 | xml=True, priority=True, important=False, 87 | queryargs=queryargs) 88 | 89 | def website_process(self, artistId, response, reply, error): 90 | if error: 91 | log.error("%s: %r: Network error retrieving artist record", PLUGIN_NAME, artistId) 92 | tuples = self.website_queue.remove(artistId) 93 | for track, album in tuples: 94 | self.album_remove_request(album) 95 | return 96 | urls = self.artist_process_metadata(artistId, response) 97 | self.website_cache[artistId] = urls 98 | tuples = self.website_queue.remove(artistId) 99 | log.debug("%s: %r: Artist Official Homepages = %r", PLUGIN_NAME, 100 | artistId, urls) 101 | for track, album in tuples: 102 | if urls: 103 | tm = track.metadata 104 | tm['website'] = urls 105 | for file in track.iterfiles(True): 106 | fm = file.metadata 107 | fm['website'] = urls 108 | self.album_remove_request(album) 109 | 110 | def album_add_request(self, album): 111 | album._requests += 1 112 | 113 | def album_remove_request(self, album): 114 | album._requests -= 1 115 | album._finalize_loading(None) 116 | 117 | def artist_process_metadata(self, artistId, response): 118 | if 'metadata' in response.children: 119 | if 'artist' in response.metadata[0].children: 120 | if 'relation_list' in response.metadata[0].artist[0].children: 121 | if 'relation' in response.metadata[0].artist[0].relation_list[0].children: 122 | return self.artist_process_relations(response.metadata[0].artist[0].relation_list[0].relation) 123 | else: 124 | log.error("%s: %r: MusicBrainz artist xml result not in correct format - %s", 125 | PLUGIN_NAME, artistId, response) 126 | return None 127 | 128 | def artist_process_relations(self, relations): 129 | urls = [] 130 | for relation in relations: 131 | if relation.type == 'official homepage' \ 132 | and 'target' in relation.children: 133 | urls.append(relation.target[0].text) 134 | return sorted(urls) 135 | 136 | 137 | register_track_metadata_processor(AlbumArtistWebsite().add_artist_website) 138 | -------------------------------------------------------------------------------- /plugins/albumartistextension/albumartistextension.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'AlbumArtist Extension' 2 | PLUGIN_AUTHOR = 'Bob Swift (rdswift)' 3 | PLUGIN_DESCRIPTION = ''' 4 | This plugin provides standardized, credited and sorted artist information 5 | for the album artist. This is useful when your tagging or renaming scripts 6 | require both the standardized artist name and the credited artist name, or 7 | more detailed information about the album artists. 8 |

9 | The information is provided in the following variables: 10 | 22 | PLEASE NOTE: Tagger scripts are required to make use of these hidden 23 | variables. 24 | ''' 25 | 26 | PLUGIN_VERSION = "0.5" 27 | PLUGIN_API_VERSIONS = ["1.4"] 28 | PLUGIN_LICENSE = "GPL-2.0 or later" 29 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 30 | 31 | from picard import config, log 32 | from picard.metadata import register_album_metadata_processor 33 | from picard.plugin import PluginPriority 34 | 35 | class AlbumArtistStdName: 36 | 37 | @staticmethod 38 | def add_artist_std_name(album, album_metadata, releaseXmlNode): 39 | albumid = releaseXmlNode.id 40 | # Test for valid XML node for the release 41 | if 'artist_credit' in releaseXmlNode.children: 42 | # Initialize variables to default values 43 | credArtist = "" 44 | stdArtist = "" 45 | sortArtist = "" 46 | aCount = 0 47 | # Get the current lists of _albumartists and _albumartists_sort 48 | metaAlbumArtists = dict.get(album_metadata,"~albumartists") 49 | metaAlbumArtists_Sort = dict.get(album_metadata,"~albumartists_sort") 50 | # The 'name_credit' child should always be there. 51 | # This check is to avoid a runtime error if it doesn't exist for some reason. 52 | if 'name_credit' in releaseXmlNode.artist_credit[0].children: 53 | for ncredit in releaseXmlNode.artist_credit[0].name_credit: 54 | # Initialize temporary variables for each loop. 55 | tempStdName = "" 56 | tempCredName = "" 57 | tempSortName = "" 58 | tempPhrase = "" 59 | # Check if there is a 'joinphrase' specified. 60 | if 'joinphrase' in ncredit.attribs: 61 | tempPhrase = ncredit.joinphrase 62 | # Set the credit name from the AlbumArtist list if the 63 | # 'Use standardized artist name' option is not checked 64 | # in Picard, otherwise use the XML information. 65 | if config.setting["standardize_artists"]: 66 | # Check if there is a 'name' specified. This will be the 67 | # credited name. 68 | if 'name' in ncredit.children: 69 | tempCredName = ncredit.name[0].text 70 | else: 71 | tempCredName = metaAlbumArtists[aCount] 72 | # The 'artist' child should always be there. 73 | # This check is to avoid a runtime error if it doesn't 74 | # exist for some reason. 75 | if 'artist' in ncredit.children: 76 | # The 'name' child should always be there. 77 | # This check is to avoid a runtime error if it 78 | # doesn't exist for some reason. 79 | if 'name' in ncredit.artist[0].children: 80 | # Set the standardized name from the AlbumArtist 81 | # list if the 'Use standardized artist name' 82 | # option is checked in Picard, otherwise use the 83 | # XML information. 84 | tempStdName = metaAlbumArtists[aCount] if config.setting["standardize_artists"] else ncredit.artist[0].name[0].text 85 | stdArtist += tempStdName + tempPhrase 86 | tCredName = tempCredName if len(tempCredName) > 0 else tempStdName 87 | credArtist += tCredName + tempPhrase 88 | if aCount < 1: 89 | album_metadata["~aaeStdPrimaryAlbumArtist"] = tempStdName 90 | album_metadata["~aaeCredPrimaryAlbumArtist"] = tCredName 91 | else: 92 | log.error("%s: %r: Missing artist 'name' in XML contents: %s", 93 | PLUGIN_NAME, albumid, releaseXmlNode) 94 | # Get the artist sort name from the 95 | # _albumartists_sort list 96 | tempSortName = metaAlbumArtists_Sort[aCount] 97 | sortArtist += tempSortName + tempPhrase 98 | if aCount < 1: 99 | album_metadata["~aaeSortPrimaryAlbumArtist"] = tempSortName 100 | else: 101 | log.error("%s: %r: Missing 'artist' in XML contents: %s", 102 | PLUGIN_NAME, albumid, releaseXmlNode) 103 | aCount += 1 104 | else: 105 | log.error("%s: %r: Missing 'name_credit' in XML contents: %s", 106 | PLUGIN_NAME, albumid, releaseXmlNode) 107 | if len(stdArtist) > 0: 108 | album_metadata["~aaeStdAlbumArtists"] = stdArtist 109 | if len(credArtist) > 0: 110 | album_metadata["~aaeCredAlbumArtists"] = credArtist 111 | if len(sortArtist) > 0: 112 | album_metadata["~aaeSortAlbumArtists"] = sortArtist 113 | if aCount > 0: 114 | album_metadata["~aaeAlbumArtistCount"] = aCount 115 | else: 116 | log.error("%s: %r: Error reading XML contents: %s", 117 | PLUGIN_NAME, albumid, releaseXmlNode) 118 | return None 119 | 120 | # Register the plugin to run at a LOW priority so that other plugins that 121 | # modify the contents of the _albumartists and _albumartists_sort lists can 122 | # complete their processing and this plugin is working with the latest 123 | # updated data. 124 | register_album_metadata_processor(AlbumArtistStdName().add_artist_std_name, priority=PluginPriority.LOW) 125 | -------------------------------------------------------------------------------- /plugins/bpm/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Changelog: 4 | # [2015-09-15] Initial version 5 | # Dependancies: 6 | # aubio, numpy 7 | # 8 | 9 | PLUGIN_NAME = u"BPM Analyzer" 10 | PLUGIN_AUTHOR = u"Len Joubert" 11 | PLUGIN_DESCRIPTION = """Calculate BPM for selected files and albums.""" 12 | PLUGIN_LICENSE = "GPL-2.0" 13 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 14 | PLUGIN_VERSION = "0.1" 15 | PLUGIN_API_VERSIONS = ["0.10", "0.15", "0.16"] 16 | #PLUGIN_INCOMPATIBLE_PLATFORMS = [ 17 | # 'win32', 'cygwyn', 'darwin', 'os2', 'os2emx', 'riscos', 'atheos'] 18 | 19 | from collections import defaultdict 20 | from subprocess import check_call 21 | from picard.album import Album, NatAlbum 22 | from picard.track import Track 23 | from picard.file import File 24 | from picard.util import encode_filename, decode_filename, partial, thread 25 | from picard.ui.options import register_options_page, OptionsPage 26 | from picard.config import TextOption, IntOption 27 | from picard.ui.itemviews import (BaseAction, register_file_action, 28 | register_album_action) 29 | from picard.plugins.bpm.ui_options_bpm import Ui_BPMOptionsPage 30 | from aubio import source, tempo 31 | from numpy import median, diff 32 | 33 | 34 | bpm_slider_settings = { 35 | 1: (44100, 1024, 512), 36 | 2: (8000, 512, 128), 37 | 3: (4000, 128, 64), 38 | } 39 | 40 | 41 | def get_file_bpm(self, path): 42 | """ Calculate the beats per minute (bpm) of a given file. 43 | path: path to the file 44 | buf_size length of FFT 45 | hop_size number of frames between two consecutive runs 46 | samplerate sampling rate of the signal to analyze 47 | """ 48 | 49 | samplerate, buf_size, hop_size = bpm_slider_settings[ 50 | BPMOptionsPage.config.setting["bpm_slider_parameter"]] 51 | mediasource = source(path.encode("utf-8"), samplerate, hop_size) 52 | samplerate = mediasource.samplerate 53 | beattracking = tempo("specdiff", buf_size, hop_size, samplerate) 54 | # List of beats, in samples 55 | beats = [] 56 | # Total number of frames read 57 | total_frames = 0 58 | 59 | while True: 60 | samples, read = mediasource() 61 | is_beat = beattracking(samples) 62 | if is_beat: 63 | this_beat = beattracking.get_last_s() 64 | beats.append(this_beat) 65 | total_frames += read 66 | if read < hop_size: 67 | break 68 | 69 | # Convert to periods and to bpm 70 | bpms = 60. / diff(beats) 71 | return median(bpms) 72 | 73 | 74 | class FileBPM(BaseAction): 75 | NAME = N_("Calculate BPM...") 76 | 77 | def _add_file_to_queue(self, file): 78 | thread.run_task( 79 | partial(self._calculate_bpm, file), 80 | partial(self._calculate_bpm_callback, file)) 81 | 82 | def callback(self, objs): 83 | for obj in objs: 84 | if isinstance(obj, Track): 85 | for file_ in obj.linked_files: 86 | self._add_file_to_queue(file_) 87 | elif isinstance(obj, File): 88 | self._add_file_to_queue(obj) 89 | 90 | def _calculate_bpm(self, file): 91 | self.tagger.window.set_statusbar_message( 92 | N_('Calculating BPM for "%(filename)s"...'), 93 | {'filename': file.filename} 94 | ) 95 | calculated_bpm = get_file_bpm(self.tagger, file.filename) 96 | # self.tagger.log.debug('%s' % (calculated_bpm)) 97 | file.metadata["bpm"] = str(round(calculated_bpm, 1)) 98 | 99 | def _calculate_bpm_callback(self, file, result=None, error=None): 100 | if not error: 101 | self.tagger.window.set_statusbar_message( 102 | N_('BPM for "%(filename)s" successfully calculated.'), 103 | {'filename': file.filename} 104 | ) 105 | else: 106 | self.tagger.window.set_statusbar_message( 107 | N_('Could not calculate BPM for "%(filename)s".'), 108 | {'filename': file.filename} 109 | ) 110 | 111 | 112 | class BPMOptionsPage(OptionsPage): 113 | 114 | NAME = "bpm" 115 | TITLE = "BPM" 116 | PARENT = "plugins" 117 | ACTIVE = True 118 | 119 | options = [ 120 | IntOption("setting", "bpm_slider_parameter", 1) 121 | ] 122 | 123 | def __init__(self, parent=None): 124 | super(BPMOptionsPage, self).__init__(parent) 125 | self.ui = Ui_BPMOptionsPage() 126 | self.ui.setupUi(self) 127 | self.ui.slider_parameter.valueChanged.connect(self.update_parameters) 128 | 129 | def load(self): 130 | cfg = self.config.setting 131 | self.ui.slider_parameter.setValue(cfg["bpm_slider_parameter"]) 132 | 133 | def save(self): 134 | cfg = self.config.setting 135 | cfg["bpm_slider_parameter"] = self.ui.slider_parameter.value() 136 | 137 | def update_parameters(self): 138 | val = self.ui.slider_parameter.value() 139 | samplerate, buf_size, hop_size = [unicode(v) for v in 140 | bpm_slider_settings[val]] 141 | self.ui.samplerate_value.setText(samplerate) 142 | self.ui.win_s_value.setText(buf_size) 143 | self.ui.hop_s_value.setText(hop_size) 144 | 145 | 146 | register_file_action(FileBPM()) 147 | register_options_page(BPMOptionsPage) 148 | -------------------------------------------------------------------------------- /plugins/bpm/options_bpm.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | BPMOptionsPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 495 10 | 273 11 | 12 | 13 | 14 | 15 | 16 | 17 | BPM Analyze Parameters: 18 | 19 | 20 | 21 | 22 | 8 23 | 80 24 | 43 25 | 19 26 | 27 | 28 | 29 | 30 | 31 | 32 | Default 33 | 34 | 35 | 36 | 37 | 38 | 20 39 | 51 40 | 391 41 | 23 42 | 43 | 44 | 45 | 1 46 | 47 | 48 | 3 49 | 50 | 51 | Qt::Horizontal 52 | 53 | 54 | QSlider::TicksBelow 55 | 56 | 57 | 1 58 | 59 | 60 | 61 | 62 | 63 | 370 64 | 80 65 | 61 66 | 19 67 | 68 | 69 | 70 | Qt::RightToLeft 71 | 72 | 73 | Super Fast 74 | 75 | 76 | 77 | 78 | 79 | 10 80 | 120 81 | 471 82 | 20 83 | 84 | 85 | 86 | Qt::Horizontal 87 | 88 | 89 | 90 | 91 | 92 | 6 93 | 140 94 | 471 95 | 91 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 106 | 107 | 108 | 109 | 110 | 111 | 112 | Number of frames between two consecutive runs: 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 133 | 134 | 135 | 136 | 137 | 138 | 139 | Length of FFT: 140 | 141 | 142 | 143 | 144 | 145 | 146 | Samplerate: 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /plugins/bpm/ui_options_bpm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'options_bpm2.ui' 4 | # 5 | # Created: Wed Sep 23 09:41:53 2015 6 | # by: PyQt4 UI code generator 4.11.3 7 | # 8 | # WARNING! All changes made in this file will be lost! 9 | 10 | from PyQt4 import QtCore, QtGui 11 | 12 | try: 13 | _fromUtf8 = QtCore.QString.fromUtf8 14 | except AttributeError: 15 | def _fromUtf8(s): 16 | return s 17 | 18 | try: 19 | _encoding = QtGui.QApplication.UnicodeUTF8 20 | def _translate(context, text, disambig): 21 | return QtGui.QApplication.translate(context, text, disambig, _encoding) 22 | except AttributeError: 23 | def _translate(context, text, disambig): 24 | return QtGui.QApplication.translate(context, text, disambig) 25 | 26 | class Ui_BPMOptionsPage(object): 27 | def setupUi(self, BPMOptionsPage): 28 | BPMOptionsPage.setObjectName(_fromUtf8("BPMOptionsPage")) 29 | BPMOptionsPage.resize(495, 273) 30 | self.gridLayout_2 = QtGui.QGridLayout(BPMOptionsPage) 31 | self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) 32 | self.bpm_options = QtGui.QGroupBox(BPMOptionsPage) 33 | self.bpm_options.setObjectName(_fromUtf8("bpm_options")) 34 | self.slider_default = QtGui.QLabel(self.bpm_options) 35 | self.slider_default.setGeometry(QtCore.QRect(8, 80, 43, 19)) 36 | self.slider_default.setToolTip(_fromUtf8("")) 37 | self.slider_default.setObjectName(_fromUtf8("slider_default")) 38 | self.slider_parameter = QtGui.QSlider(self.bpm_options) 39 | self.slider_parameter.setGeometry(QtCore.QRect(20, 51, 391, 23)) 40 | self.slider_parameter.setMinimum(1) 41 | self.slider_parameter.setMaximum(3) 42 | self.slider_parameter.setOrientation(QtCore.Qt.Horizontal) 43 | self.slider_parameter.setTickPosition(QtGui.QSlider.TicksBelow) 44 | self.slider_parameter.setTickInterval(1) 45 | self.slider_parameter.setObjectName(_fromUtf8("slider_parameter")) 46 | self.slider_super_fast = QtGui.QLabel(self.bpm_options) 47 | self.slider_super_fast.setGeometry(QtCore.QRect(370, 80, 61, 19)) 48 | self.slider_super_fast.setLayoutDirection(QtCore.Qt.RightToLeft) 49 | self.slider_super_fast.setObjectName(_fromUtf8("slider_super_fast")) 50 | self.line = QtGui.QFrame(self.bpm_options) 51 | self.line.setGeometry(QtCore.QRect(10, 120, 471, 20)) 52 | self.line.setFrameShape(QtGui.QFrame.HLine) 53 | self.line.setFrameShadow(QtGui.QFrame.Sunken) 54 | self.line.setObjectName(_fromUtf8("line")) 55 | self.horizontalLayoutWidget = QtGui.QWidget(self.bpm_options) 56 | self.horizontalLayoutWidget.setGeometry(QtCore.QRect(6, 140, 471, 91)) 57 | self.horizontalLayoutWidget.setObjectName(_fromUtf8("horizontalLayoutWidget")) 58 | self.gridLayout = QtGui.QGridLayout(self.horizontalLayoutWidget) 59 | self.gridLayout.setMargin(0) 60 | self.gridLayout.setObjectName(_fromUtf8("gridLayout")) 61 | self.samplerate_value = QtGui.QLabel(self.horizontalLayoutWidget) 62 | self.samplerate_value.setText(_fromUtf8("")) 63 | self.samplerate_value.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) 64 | self.samplerate_value.setObjectName(_fromUtf8("samplerate_value")) 65 | self.gridLayout.addWidget(self.samplerate_value, 2, 1, 1, 1) 66 | self.hop_s_label = QtGui.QLabel(self.horizontalLayoutWidget) 67 | self.hop_s_label.setObjectName(_fromUtf8("hop_s_label")) 68 | self.gridLayout.addWidget(self.hop_s_label, 1, 0, 1, 1) 69 | self.win_s_value = QtGui.QLabel(self.horizontalLayoutWidget) 70 | self.win_s_value.setText(_fromUtf8("")) 71 | self.win_s_value.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) 72 | self.win_s_value.setObjectName(_fromUtf8("win_s_value")) 73 | self.gridLayout.addWidget(self.win_s_value, 0, 1, 1, 1) 74 | self.hop_s_value = QtGui.QLabel(self.horizontalLayoutWidget) 75 | self.hop_s_value.setText(_fromUtf8("")) 76 | self.hop_s_value.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) 77 | self.hop_s_value.setObjectName(_fromUtf8("hop_s_value")) 78 | self.gridLayout.addWidget(self.hop_s_value, 1, 1, 1, 1) 79 | self.win_s_label = QtGui.QLabel(self.horizontalLayoutWidget) 80 | self.win_s_label.setObjectName(_fromUtf8("win_s_label")) 81 | self.gridLayout.addWidget(self.win_s_label, 0, 0, 1, 1) 82 | self.samplerate_label = QtGui.QLabel(self.horizontalLayoutWidget) 83 | self.samplerate_label.setObjectName(_fromUtf8("samplerate_label")) 84 | self.gridLayout.addWidget(self.samplerate_label, 2, 0, 1, 1) 85 | self.gridLayout.setColumnStretch(0, 1) 86 | self.gridLayout.setColumnStretch(1, 1) 87 | self.gridLayout_2.addWidget(self.bpm_options, 0, 0, 1, 1) 88 | 89 | self.retranslateUi(BPMOptionsPage) 90 | QtCore.QMetaObject.connectSlotsByName(BPMOptionsPage) 91 | 92 | def retranslateUi(self, BPMOptionsPage): 93 | self.bpm_options.setTitle(_translate("BPMOptionsPage", "BPM Analyze Parameters:", None)) 94 | self.slider_default.setText(_translate("BPMOptionsPage", "Default", None)) 95 | self.slider_super_fast.setText(_translate("BPMOptionsPage", "Super Fast", None)) 96 | self.hop_s_label.setText(_translate("BPMOptionsPage", "Number of frames between two consecutive runs:", None)) 97 | self.win_s_label.setText(_translate("BPMOptionsPage", "Length of FFT:", None)) 98 | self.samplerate_label.setText(_translate("BPMOptionsPage", "Samplerate:", None)) 99 | 100 | -------------------------------------------------------------------------------- /plugins/classical_work_collection/.idea/classical_work_collection.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | -------------------------------------------------------------------------------- /plugins/classical_work_collection/confirm.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | ConfirmDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 440 10 | 130 11 | 12 | 13 | 14 | Dialog 15 | 16 | 17 | 18 | 19 | 20 20 | 20 21 | 391 22 | 71 23 | 24 | 25 | 26 | Please confirm:- 27 | 28 | 29 | 30 | 31 | 10 32 | 30 33 | 371 34 | 16 35 | 36 | 37 | 38 | Adding xxxxxx works to the collection "Collection" 39 | 40 | 41 | 42 | 43 | 44 | 10 45 | 50 46 | 381 47 | 16 48 | 49 | 50 | 51 | All xxxxx selected works are already in the collection - no more will be added. 52 | 53 | 54 | 55 | 56 | 57 | 58 | 10 59 | 90 60 | 341 61 | 32 62 | 63 | 64 | 65 | Qt::Horizontal 66 | 67 | 68 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 69 | 70 | 71 | 72 | 73 | 74 | 75 | buttonBox 76 | accepted() 77 | ConfirmDialog 78 | accept() 79 | 80 | 81 | 238 82 | 94 83 | 84 | 85 | 157 86 | 274 87 | 88 | 89 | 90 | 91 | buttonBox 92 | rejected() 93 | ConfirmDialog 94 | reject() 95 | 96 | 97 | 306 98 | 100 99 | 100 | 101 | 286 102 | 274 103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /plugins/classical_work_collection/readme.md: -------------------------------------------------------------------------------- 1 | This is the documentation for version 0.1 "Classical Work Collections". There may be beta versions later than this - check [my github site](https://github.com/MetaTunes/picard-plugins/releases) for newer releases. 2 | 3 | This plugin adds a context menu 'add works to collections', which operates from track or album selections 4 | regardless of whether a file is present. It presents a dialog box showing available work collections. Select the 5 | collection(s)and a confirmation dialog appears. Confirming will add works from all the selected tracks to the 6 | selected collections. 7 | If the plugin 'Classical Extras' has been used then all parent works will also be added. 8 | 9 | The first dialog box gives options: 10 | * Maximum number of works to be added at a time: The default is 200. More than this may result in "URI too large" error (even though the MB documentation says 400 should work). If a "URI too large" error occurs, reduce the limit." 11 | * Provide analysis of existing collection and new works before updating: Selecting this (the default) will provide information about how many of the selected works are already in the selected collection(s) and only new works will be submitted. Deselecting it will result in all selected works being submitted, but will almost certainly be faster as existing works can only be looked up at the rate of 100 per sec. 12 | 13 | Assuming the default on the second option above, the second dialog box (one per collection) will provide the analysis described. 14 | -------------------------------------------------------------------------------- /plugins/classical_work_collection/select_collections.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | CollectionsDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 408 10 | 334 11 | 12 | 13 | 14 | Dialog 15 | 16 | 17 | 18 | 19 | 40 20 | 280 21 | 341 22 | 32 23 | 24 | 25 | 26 | Qt::Horizontal 27 | 28 | 29 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 30 | 31 | 32 | 33 | 34 | 35 | 0 36 | 10 37 | 401 38 | 161 39 | 40 | 41 | 42 | Select collections:- 43 | 44 | 45 | 46 | 47 | 10 48 | 20 49 | 371 50 | 101 51 | 52 | 53 | 54 | QAbstractItemView::MultiSelection 55 | 56 | 57 | 58 | 59 | 60 | 10 61 | 130 62 | 431 63 | 16 64 | 65 | 66 | 67 | Highlight the collections into which to add the works from the selected tracks 68 | 69 | 70 | 71 | 72 | 73 | 74 | 310 75 | 170 76 | 71 77 | 22 78 | 79 | 80 | 81 | 1 82 | 83 | 84 | 400 85 | 86 | 87 | 88 | 89 | 90 | 20 91 | 170 92 | 281 93 | 16 94 | 95 | 96 | 97 | Maximum number of works to be added at a time 98 | 99 | 100 | 101 | 102 | 103 | 20 104 | 200 105 | 261 106 | 16 107 | 108 | 109 | 110 | (multiple submissions will be generated automatically 111 | 112 | 113 | 114 | 115 | 116 | 20 117 | 210 118 | 291 119 | 16 120 | 121 | 122 | 123 | if the total number of works to be added exceeds this max) 124 | 125 | 126 | 127 | 128 | 129 | 20 130 | 230 131 | 361 132 | 31 133 | 134 | 135 | 136 | Qt::RightToLeft 137 | 138 | 139 | Provide analysis of existing collection and new works before updating? 140 | 141 | 142 | 143 | 144 | 145 | 20 146 | 250 147 | 371 148 | 16 149 | 150 | 151 | 152 | (Faster if unchecked, but less informative) 153 | 154 | 155 | 156 | 157 | 158 | 20 159 | 180 160 | 261 161 | 16 162 | 163 | 164 | 165 | More than 200 may result in "URI too large" error 166 | 167 | 168 | 169 | 170 | 171 | 172 | buttonBox 173 | accepted() 174 | CollectionsDialog 175 | accept() 176 | 177 | 178 | 258 179 | 264 180 | 181 | 182 | 157 183 | 274 184 | 185 | 186 | 187 | 188 | buttonBox 189 | rejected() 190 | CollectionsDialog 191 | reject() 192 | 193 | 194 | 316 195 | 260 196 | 197 | 198 | 286 199 | 274 200 | 201 | 202 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /plugins/classical_work_collection/ui_confirm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'C:\Users\Mark\Documents\Mark's documents\Music\Picard\Classical Works Collection development\classical_work_collection\confirm.ui' 4 | # 5 | # Created: Wed May 16 11:07:22 2018 6 | # by: PyQt4 UI code generator 4.10 7 | # 8 | # WARNING! All changes made in this file will be lost! 9 | 10 | from PyQt4 import QtCore, QtGui 11 | 12 | try: 13 | _fromUtf8 = QtCore.QString.fromUtf8 14 | except AttributeError: 15 | def _fromUtf8(s): 16 | return s 17 | 18 | try: 19 | _encoding = QtGui.QApplication.UnicodeUTF8 20 | def _translate(context, text, disambig): 21 | return QtGui.QApplication.translate(context, text, disambig, _encoding) 22 | except AttributeError: 23 | def _translate(context, text, disambig): 24 | return QtGui.QApplication.translate(context, text, disambig) 25 | 26 | class Ui_ConfirmDialog(object): 27 | def setupUi(self, ConfirmDialog): 28 | ConfirmDialog.setObjectName(_fromUtf8("ConfirmDialog")) 29 | ConfirmDialog.resize(440, 130) 30 | self.groupBox = QtGui.QGroupBox(ConfirmDialog) 31 | self.groupBox.setGeometry(QtCore.QRect(20, 20, 391, 71)) 32 | self.groupBox.setObjectName(_fromUtf8("groupBox")) 33 | self.label = QtGui.QLabel(self.groupBox) 34 | self.label.setGeometry(QtCore.QRect(10, 30, 371, 16)) 35 | self.label.setObjectName(_fromUtf8("label")) 36 | self.label_2 = QtGui.QLabel(self.groupBox) 37 | self.label_2.setGeometry(QtCore.QRect(10, 50, 381, 16)) 38 | self.label_2.setObjectName(_fromUtf8("label_2")) 39 | self.buttonBox = QtGui.QDialogButtonBox(ConfirmDialog) 40 | self.buttonBox.setGeometry(QtCore.QRect(10, 90, 341, 32)) 41 | self.buttonBox.setOrientation(QtCore.Qt.Horizontal) 42 | self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok) 43 | self.buttonBox.setObjectName(_fromUtf8("buttonBox")) 44 | 45 | self.retranslateUi(ConfirmDialog) 46 | QtCore.QObject.connect(self.buttonBox, QtCore.SIGNAL(_fromUtf8("accepted()")), ConfirmDialog.accept) 47 | QtCore.QObject.connect(self.buttonBox, QtCore.SIGNAL(_fromUtf8("rejected()")), ConfirmDialog.reject) 48 | QtCore.QMetaObject.connectSlotsByName(ConfirmDialog) 49 | 50 | def retranslateUi(self, ConfirmDialog): 51 | ConfirmDialog.setWindowTitle(_translate("ConfirmDialog", "Dialog", None)) 52 | self.groupBox.setTitle(_translate("ConfirmDialog", "Please confirm:-", None)) 53 | self.label.setText(_translate("ConfirmDialog", "Adding xxxxxx works to the collection \"Collection\"", None)) 54 | self.label_2.setText(_translate("ConfirmDialog", "All xxxxx selected works are already in the collection - no more will be added.", None)) 55 | 56 | -------------------------------------------------------------------------------- /plugins/classical_work_collection/ui_select_collections.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'C:\Users\Mark\Documents\Mark's documents\Music\Picard\Classical Works Collection development\classical_work_collection\select_collections.ui' 4 | # 5 | # Created: Thu May 17 14:20:56 2018 6 | # by: PyQt4 UI code generator 4.10 7 | # 8 | # WARNING! All changes made in this file will be lost! 9 | 10 | from PyQt4 import QtCore, QtGui 11 | 12 | try: 13 | _fromUtf8 = QtCore.QString.fromUtf8 14 | except AttributeError: 15 | def _fromUtf8(s): 16 | return s 17 | 18 | try: 19 | _encoding = QtGui.QApplication.UnicodeUTF8 20 | def _translate(context, text, disambig): 21 | return QtGui.QApplication.translate(context, text, disambig, _encoding) 22 | except AttributeError: 23 | def _translate(context, text, disambig): 24 | return QtGui.QApplication.translate(context, text, disambig) 25 | 26 | class Ui_CollectionsDialog(object): 27 | def setupUi(self, CollectionsDialog): 28 | CollectionsDialog.setObjectName(_fromUtf8("CollectionsDialog")) 29 | CollectionsDialog.resize(408, 334) 30 | self.buttonBox = QtGui.QDialogButtonBox(CollectionsDialog) 31 | self.buttonBox.setGeometry(QtCore.QRect(40, 280, 341, 32)) 32 | self.buttonBox.setOrientation(QtCore.Qt.Horizontal) 33 | self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok) 34 | self.buttonBox.setObjectName(_fromUtf8("buttonBox")) 35 | self.groupBox = QtGui.QGroupBox(CollectionsDialog) 36 | self.groupBox.setGeometry(QtCore.QRect(0, 10, 401, 161)) 37 | self.groupBox.setObjectName(_fromUtf8("groupBox")) 38 | self.collection_list = QtGui.QListWidget(self.groupBox) 39 | self.collection_list.setGeometry(QtCore.QRect(10, 20, 371, 101)) 40 | self.collection_list.setSelectionMode(QtGui.QAbstractItemView.MultiSelection) 41 | self.collection_list.setObjectName(_fromUtf8("collection_list")) 42 | self.label = QtGui.QLabel(self.groupBox) 43 | self.label.setGeometry(QtCore.QRect(10, 130, 431, 16)) 44 | self.label.setObjectName(_fromUtf8("label")) 45 | self.max_works = QtGui.QSpinBox(CollectionsDialog) 46 | self.max_works.setGeometry(QtCore.QRect(310, 170, 71, 22)) 47 | self.max_works.setMinimum(1) 48 | self.max_works.setMaximum(400) 49 | self.max_works.setObjectName(_fromUtf8("max_works")) 50 | self.label_2 = QtGui.QLabel(CollectionsDialog) 51 | self.label_2.setGeometry(QtCore.QRect(20, 170, 281, 16)) 52 | self.label_2.setObjectName(_fromUtf8("label_2")) 53 | self.label_3 = QtGui.QLabel(CollectionsDialog) 54 | self.label_3.setGeometry(QtCore.QRect(20, 200, 261, 16)) 55 | self.label_3.setObjectName(_fromUtf8("label_3")) 56 | self.label_4 = QtGui.QLabel(CollectionsDialog) 57 | self.label_4.setGeometry(QtCore.QRect(20, 210, 291, 16)) 58 | self.label_4.setObjectName(_fromUtf8("label_4")) 59 | self.provide_analysis = QtGui.QCheckBox(CollectionsDialog) 60 | self.provide_analysis.setGeometry(QtCore.QRect(20, 230, 361, 31)) 61 | self.provide_analysis.setLayoutDirection(QtCore.Qt.RightToLeft) 62 | self.provide_analysis.setObjectName(_fromUtf8("provide_analysis")) 63 | self.label_5 = QtGui.QLabel(CollectionsDialog) 64 | self.label_5.setGeometry(QtCore.QRect(20, 250, 371, 16)) 65 | self.label_5.setObjectName(_fromUtf8("label_5")) 66 | self.label_7 = QtGui.QLabel(CollectionsDialog) 67 | self.label_7.setGeometry(QtCore.QRect(20, 180, 261, 16)) 68 | self.label_7.setObjectName(_fromUtf8("label_7")) 69 | 70 | self.retranslateUi(CollectionsDialog) 71 | QtCore.QObject.connect(self.buttonBox, QtCore.SIGNAL(_fromUtf8("accepted()")), CollectionsDialog.accept) 72 | QtCore.QObject.connect(self.buttonBox, QtCore.SIGNAL(_fromUtf8("rejected()")), CollectionsDialog.reject) 73 | QtCore.QMetaObject.connectSlotsByName(CollectionsDialog) 74 | 75 | def retranslateUi(self, CollectionsDialog): 76 | CollectionsDialog.setWindowTitle(_translate("CollectionsDialog", "Dialog", None)) 77 | self.groupBox.setTitle(_translate("CollectionsDialog", "Select collections:-", None)) 78 | self.label.setText(_translate("CollectionsDialog", "Highlight the collections into which to add the works from the selected tracks", None)) 79 | self.label_2.setText(_translate("CollectionsDialog", "Maximum number of works to be added at a time", None)) 80 | self.label_3.setText(_translate("CollectionsDialog", "(multiple submissions will be generated automatically", None)) 81 | self.label_4.setText(_translate("CollectionsDialog", "if the total number of works to be added exceeds this max)", None)) 82 | self.provide_analysis.setText(_translate("CollectionsDialog", "Provide analysis of existing collection and new works before updating? ", None)) 83 | self.label_5.setText(_translate("CollectionsDialog", "(Faster if unchecked, but less informative)", None)) 84 | self.label_7.setText(_translate("CollectionsDialog", "More than 200 may result in \"URI too large\" error", None)) 85 | 86 | -------------------------------------------------------------------------------- /plugins/classicdiscnumber/classicdiscnumber.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'Classic Disc Numbers' 2 | PLUGIN_AUTHOR = 'Lukas Lalinsky' 3 | PLUGIN_DESCRIPTION = '''Moves disc numbers and subtitles from the separate tags to album titles.''' 4 | PLUGIN_VERSION = "0.1" 5 | PLUGIN_API_VERSIONS = ["0.15"] 6 | 7 | from picard.metadata import register_track_metadata_processor 8 | import re 9 | 10 | 11 | def add_discnumbers(tagger, metadata, release, track): 12 | if int(metadata["totaldiscs"] or "0") > 1: 13 | if "discsubtitle" in metadata: 14 | metadata["album"] = "%s (disc %s: %s)" % ( 15 | metadata["album"], metadata["discnumber"], 16 | metadata["discsubtitle"]) 17 | else: 18 | metadata["album"] = "%s (disc %s)" % ( 19 | metadata["album"], metadata["discnumber"]) 20 | 21 | register_track_metadata_processor(add_discnumbers) 22 | -------------------------------------------------------------------------------- /plugins/cuesheet/cuesheet.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | PLUGIN_NAME = u"Generate Cuesheet" 4 | PLUGIN_AUTHOR = u"Lukáš Lalinský" 5 | PLUGIN_DESCRIPTION = "Generate cuesheet (.cue file) from an album." 6 | PLUGIN_VERSION = "0.2" 7 | PLUGIN_API_VERSIONS = ["0.10", "0.15"] 8 | 9 | 10 | import os.path 11 | import re 12 | from PyQt4 import QtCore, QtGui 13 | from picard.util import find_existing_path, encode_filename 14 | from picard.ui.itemviews import BaseAction, register_album_action 15 | 16 | 17 | _whitespace_re = re.compile('\s', re.UNICODE) 18 | _split_re = re.compile('\s*("[^"]*"|[^ ]+)\s*', re.UNICODE) 19 | 20 | 21 | def msfToMs(msf): 22 | msf = msf.split(":") 23 | return ((int(msf[0]) * 60 + int(msf[1])) * 75 + int(msf[2])) * 1000 / 75 24 | 25 | 26 | class CuesheetTrack(list): 27 | 28 | def __init__(self, cuesheet, index): 29 | list.__init__(self) 30 | self.cuesheet = cuesheet 31 | self.index = index 32 | 33 | def set(self, *args): 34 | self.append(args) 35 | 36 | def find(self, prefix): 37 | return [i for i in self if tuple(i[:len(prefix)]) == tuple(prefix)] 38 | 39 | def getTrackNumber(self): 40 | return self.index 41 | 42 | def getLength(self): 43 | try: 44 | nextTrack = self.cuesheet.tracks[self.index + 1] 45 | index0 = self.find((u"INDEX", u"01")) 46 | index1 = nextTrack.find((u"INDEX", u"01")) 47 | return msfToMs(index1[0][2]) - msfToMs(index0[0][2]) 48 | except IndexError: 49 | return 0 50 | 51 | def getField(self, prefix): 52 | try: 53 | return self.find(prefix)[0][len(prefix)] 54 | except IndexError: 55 | return u"" 56 | 57 | def getArtist(self): 58 | return self.getField((u"PERFORMER",)) 59 | 60 | def getTitle(self): 61 | return self.getField((u"TITLE",)) 62 | 63 | def setArtist(self, artist): 64 | found = False 65 | for item in self: 66 | if item[0] == u"PERFORMER": 67 | if not found: 68 | item[1] = artist 69 | found = True 70 | else: 71 | del item 72 | if not found: 73 | self.append((u"PERFORMER", artist)) 74 | 75 | artist = property(getArtist, setArtist) 76 | 77 | 78 | class Cuesheet(object): 79 | 80 | def __init__(self, filename): 81 | self.filename = filename 82 | self.tracks = [] 83 | 84 | def read(self): 85 | with open(encode_filename(self.filename)) as f: 86 | self.parse(f.readlines()) 87 | 88 | def unquote(self, string): 89 | if string.startswith('"'): 90 | if string.endswith('"'): 91 | return string[1:-1] 92 | else: 93 | return string[1:] 94 | return string 95 | 96 | def quote(self, string): 97 | if _whitespace_re.search(string): 98 | return '"' + string.replace('"', '\'') + '"' 99 | return string 100 | 101 | def parse(self, lines): 102 | track = CuesheetTrack(self, 0) 103 | self.tracks = [track] 104 | isUnicode = False 105 | for line in lines: 106 | # remove BOM 107 | if line.startswith('\xfe\xff'): 108 | isUnicode = True 109 | line = line[1:] 110 | # decode to unicode string 111 | line = line.strip() 112 | if isUnicode: 113 | line = line.decode('UTF-8', 'replace') 114 | else: 115 | line = line.decode('ISO-8859-1', 'replace') 116 | # parse the line 117 | split = [self.unquote(s) for s in _split_re.findall(line)] 118 | keyword = split[0].upper() 119 | if keyword == 'TRACK': 120 | trackNum = int(split[1]) 121 | track = CuesheetTrack(self, trackNum) 122 | self.tracks.append(track) 123 | track.append(split) 124 | 125 | def write(self): 126 | lines = [] 127 | for track in self.tracks: 128 | num = track.index 129 | for line in track: 130 | indent = 0 131 | if num > 0: 132 | if line[0] == "TRACK": 133 | indent = 2 134 | elif line[0] != "FILE": 135 | indent = 4 136 | line2 = u" ".join([self.quote(s) for s in line]) 137 | lines.append(" " * indent + line2.encode("UTF-8") + "\n") 138 | with open(encode_filename(self.filename), "wt") as f: 139 | f.writelines(lines) 140 | 141 | 142 | class GenerateCuesheet(BaseAction): 143 | NAME = "Generate &Cuesheet..." 144 | 145 | def callback(self, objs): 146 | album = objs[0] 147 | current_directory = self.config.persist["current_directory"] or QtCore.QDir.homePath() 148 | current_directory = find_existing_path(unicode(current_directory)) 149 | filename, selected_format = QtGui.QFileDialog.getSaveFileNameAndFilter( 150 | None, "", current_directory, "Cuesheet (*.cue)") 151 | if filename: 152 | filename = unicode(filename) 153 | cuesheet = Cuesheet(filename) 154 | #try: cuesheet.read() 155 | #except IOError: pass 156 | while len(cuesheet.tracks) <= len(album.tracks): 157 | track = CuesheetTrack(cuesheet, len(cuesheet.tracks)) 158 | cuesheet.tracks.append(track) 159 | #if len(cuesheet.tracks) > len(album.tracks) - 1: 160 | # cuesheet.tracks = cuesheet.tracks[0:len(album.tracks)+1] 161 | 162 | t = cuesheet.tracks[0] 163 | t.set("PERFORMER", album.metadata["albumartist"]) 164 | t.set("TITLE", album.metadata["album"]) 165 | t.set("REM", "MUSICBRAINZ_ALBUM_ID", album.metadata["musicbrainz_albumid"]) 166 | t.set("REM", "MUSICBRAINZ_ALBUM_ARTIST_ID", album.metadata["musicbrainz_albumartistid"]) 167 | if "date" in album.metadata: 168 | t.set("REM", "DATE", album.metadata["date"]) 169 | index = 0.0 170 | for i, track in enumerate(album.tracks): 171 | mm = index / 60.0 172 | ss = (mm - int(mm)) * 60.0 173 | ff = (ss - int(ss)) * 75.0 174 | index += track.metadata.length / 1000.0 175 | t = cuesheet.tracks[i + 1] 176 | t.set("TRACK", "%02d" % (i + 1), "AUDIO") 177 | t.set("PERFORMER", track.metadata["artist"]) 178 | t.set("TITLE", track.metadata["title"]) 179 | t.set("REM", "MUSICBRAINZ_TRACK_ID", track.metadata["musicbrainz_trackid"]) 180 | t.set("REM", "MUSICBRAINZ_ARTIST_ID", track.metadata["musicbrainz_artistid"]) 181 | t.set("INDEX", "01", "%02d:%02d:%02d" % (mm, ss, ff)) 182 | for file in track.linked_files: 183 | audio_filename = file.filename 184 | if os.path.dirname(filename) == os.path.dirname(audio_filename): 185 | audio_filename = os.path.basename(audio_filename) 186 | cuesheet.tracks[i].set("FILE", audio_filename, "MP3") 187 | 188 | cuesheet.write() 189 | 190 | 191 | action = GenerateCuesheet() 192 | register_album_action(action) 193 | -------------------------------------------------------------------------------- /plugins/decode_cyrillic/decode_cyrillic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This is the Decode Cyrillic plugin for MusicBrainz Picard. 4 | # Copyright (C) 2015 aeontech 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | 24 | from __future__ import print_function 25 | PLUGIN_NAME = u"Decode Cyrillic" 26 | PLUGIN_AUTHOR = u"aeontech" 27 | PLUGIN_DESCRIPTION = u''' 28 | This plugin helps you quickly convert mis-encoded cyrillic Windows-1251 tags 29 | to proper UTF-8 encoded strings. If your track/album names look something like 30 | "Àëèñà â ñò›àíå ÷óäåñ", run this plugin from the context menu 31 | before running the "Lookup" or "Scan" tools 32 | ''' 33 | PLUGIN_VERSION = "1.0" 34 | PLUGIN_API_VERSIONS = ["1.0"] 35 | PLUGIN_LICENSE = "MIT" 36 | PLUGIN_LICENSE_URL = "https://opensource.org/licenses/MIT" 37 | 38 | from picard import log 39 | from picard.cluster import Cluster 40 | from picard.ui.itemviews import BaseAction, register_cluster_action 41 | 42 | _decode_tags = [ 43 | 'title', 44 | 'albumartist', 45 | 'artist', 46 | 'album', 47 | 'artistsort' 48 | ] 49 | # _from_encoding = "latin1" 50 | # _to_encoding = "cp1251" 51 | 52 | 53 | # TODO: 54 | # - extend to support multiple codepage decoding, not just cp1251->latin1 55 | # instead, try the common variations, and show a dialog to the user, 56 | # allowing him to select the correct transcoding. See 2cyr.com for example. 57 | # - also see http://stackoverflow.com/questions/23326531/how-to-decode-cp1252-string 58 | 59 | class DecodeCyrillic(BaseAction): 60 | NAME = "Unmangle cyrillic metadata" 61 | 62 | def unmangle(self, tag, value): 63 | try: 64 | unmangled_value = value.encode('latin1').decode('cp1251') 65 | except UnicodeEncodeError: 66 | unmangled_value = value 67 | log.debug("%s: could not unmangle tag %s; original value: %s" % (PLUGIN_NAME, tag, value)) 68 | return unmangled_value 69 | 70 | def callback(self, objs): 71 | for cluster in objs: 72 | if not isinstance(cluster, Cluster): 73 | continue 74 | 75 | for tag in _decode_tags: 76 | if not (tag in cluster.metadata): 77 | continue 78 | 79 | cluster.metadata[tag] = self.unmangle(tag, cluster.metadata[tag]) 80 | 81 | log.debug("cluster name is %s by %s" % (cluster.metadata['album'], cluster.metadata['albumartist'])) 82 | 83 | for i, file in enumerate(cluster.files): 84 | 85 | log.debug("%s: Trying to unmangle file - original metadata %s" % (PLUGIN_NAME, file.orig_metadata)) 86 | 87 | for tag in _decode_tags: 88 | 89 | if not (tag in file.metadata): 90 | continue 91 | 92 | unmangled_tag = self.unmangle(tag, file.metadata[tag]) 93 | 94 | file.orig_metadata[tag] = unmangled_tag 95 | file.metadata[tag] = unmangled_tag 96 | 97 | file.orig_metadata.changed = True 98 | file.metadata.changed = True 99 | file.update(signal=True) 100 | 101 | cluster.update() 102 | 103 | register_cluster_action(DecodeCyrillic()) 104 | -------------------------------------------------------------------------------- /plugins/discnumber/discnumber.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'Disc Numbers' 2 | PLUGIN_AUTHOR = 'Lukas Lalinsky' 3 | PLUGIN_DESCRIPTION = '''Moves disc numbers and subtitles from album titles to separate tags. For example:
4 | "Aerial (disc 1: A Sea of Honey)" 5 | ''' 10 | PLUGIN_VERSION = "0.1" 11 | PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15"] 12 | 13 | from picard.metadata import register_album_metadata_processor 14 | import re 15 | 16 | _discnumber_re = re.compile(r"\s+\(disc (\d+)(?::\s+([^)]+))?\)") 17 | 18 | 19 | def remove_discnumbers(tagger, metadata, release): 20 | matches = _discnumber_re.search(metadata["album"]) 21 | if matches: 22 | metadata["discnumber"] = matches.group(1) 23 | if matches.group(2): 24 | metadata["discsubtitle"] = matches.group(2) 25 | metadata["album"] = _discnumber_re.sub('', metadata["album"]) 26 | 27 | register_album_metadata_processor(remove_discnumbers) 28 | -------------------------------------------------------------------------------- /plugins/fanarttv/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Philipp Wolfer 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License 7 | # as published by the Free Software Foundation; either version 2 8 | # of the License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software 17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 18 | # 02110-1301, USA. 19 | 20 | PLUGIN_NAME = u'fanart.tv cover art' 21 | PLUGIN_AUTHOR = u'Philipp Wolfer' 22 | PLUGIN_DESCRIPTION = u'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/' 23 | PLUGIN_VERSION = "0.5" 24 | PLUGIN_API_VERSIONS = ["1.4.0"] 25 | PLUGIN_LICENSE = "GPL-2.0" 26 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 27 | 28 | import json 29 | import traceback 30 | from PyQt4.QtCore import QUrl 31 | from PyQt4.QtNetwork import QNetworkReply 32 | from picard import config, log 33 | from picard.coverart.providers import CoverArtProvider, register_cover_art_provider 34 | from picard.coverart.image import CoverArtImage 35 | from picard.util import partial 36 | from picard.ui.options import register_options_page, OptionsPage 37 | from picard.config import TextOption, BoolOption 38 | from picard.plugins.fanarttv.ui_options_fanarttv import Ui_FanartTvOptionsPage 39 | 40 | FANART_HOST = "webservice.fanart.tv" 41 | FANART_PORT = 80 42 | FANART_APIKEY = "21305dd1589766f4d544535ad4df12f4" 43 | 44 | OPTION_CDART_ALWAYS = "always" 45 | OPTION_CDART_NEVER = "never" 46 | OPTION_CDART_NOALBUMART = "noalbumart" 47 | 48 | 49 | def cover_sort_key(cover): 50 | """For sorting a list of cover arts by likes.""" 51 | try: 52 | return int(cover["likes"]) if "likes" in cover else 0 53 | except ValueError: 54 | return 0 55 | 56 | 57 | class FanartTvCoverArtImage(CoverArtImage): 58 | 59 | """Image from Cover Art Archive""" 60 | 61 | support_types = True 62 | sourceprefix = u"FATV" 63 | 64 | 65 | class CoverArtProviderFanartTv(CoverArtProvider): 66 | 67 | """Use fanart.tv to get cover art""" 68 | 69 | NAME = "fanart.tv" 70 | 71 | def enabled(self): 72 | return self._client_key != "" and \ 73 | super(CoverArtProviderFanartTv, self).enabled() and \ 74 | not self.coverart.front_image_found 75 | 76 | def queue_downloads(self): 77 | release_group_id = self.metadata["musicbrainz_releasegroupid"] 78 | path = "/v3/music/albums/%s" % \ 79 | (release_group_id, ) 80 | queryargs = {"api_key": QUrl.toPercentEncoding(FANART_APIKEY), 81 | "client_key": QUrl.toPercentEncoding(self._client_key), 82 | } 83 | log.debug("CoverArtProviderFanartTv.queue_downloads: %s" % path) 84 | self.album.tagger.xmlws.download( 85 | FANART_HOST, 86 | FANART_PORT, 87 | path, 88 | partial(self._json_downloaded, release_group_id), 89 | priority=True, 90 | important=False, 91 | queryargs=queryargs) 92 | self.album._requests += 1 93 | return CoverArtProvider.WAIT 94 | 95 | @property 96 | def _client_key(self): 97 | return config.setting["fanarttv_client_key"] 98 | 99 | def _json_downloaded(self, release_group_id, data, reply, error): 100 | self.album._requests -= 1 101 | 102 | if error: 103 | if error != QNetworkReply.ContentNotFoundError: 104 | error_level = log.error 105 | else: 106 | error_level = log.debug 107 | error_level("Problem requesting metadata in fanart.tv plugin: %s", error) 108 | else: 109 | try: 110 | response = json.loads(data) 111 | release = response["albums"][release_group_id] 112 | 113 | if "albumcover" in release: 114 | covers = release["albumcover"] 115 | types = ["front"] 116 | self._select_and_add_cover_art(covers, types) 117 | 118 | if "cdart" in release and \ 119 | (config.setting["fanarttv_use_cdart"] == OPTION_CDART_ALWAYS 120 | or (config.setting["fanarttv_use_cdart"] == OPTION_CDART_NOALBUMART 121 | and "albumcover" not in release)): 122 | covers = release["cdart"] 123 | types = ["medium"] 124 | if not "albumcover" in release: 125 | types.append("front") 126 | self._select_and_add_cover_art(covers, types) 127 | except: 128 | log.error("Problem processing downloaded metadata in fanart.tv plugin: %s", traceback.format_exc()) 129 | 130 | self.next_in_queue() 131 | 132 | def _select_and_add_cover_art(self, covers, types): 133 | covers = sorted(covers, key=cover_sort_key, reverse=True) 134 | url = covers[0]["url"] 135 | log.debug("CoverArtProviderFanartTv found artwork %s" % url) 136 | self.queue_put(FanartTvCoverArtImage(url, types=types)) 137 | 138 | 139 | class FanartTvOptionsPage(OptionsPage): 140 | 141 | NAME = "fanarttv" 142 | TITLE = "fanart.tv" 143 | PARENT = "plugins" 144 | 145 | options = [ 146 | TextOption("setting", "fanarttv_client_key", ""), 147 | TextOption("setting", "fanarttv_use_cdart", OPTION_CDART_NOALBUMART), 148 | ] 149 | 150 | def __init__(self, parent=None): 151 | super(FanartTvOptionsPage, self).__init__(parent) 152 | self.ui = Ui_FanartTvOptionsPage() 153 | self.ui.setupUi(self) 154 | 155 | def load(self): 156 | self.ui.fanarttv_client_key.setText(config.setting["fanarttv_client_key"]) 157 | if config.setting["fanarttv_use_cdart"] == OPTION_CDART_ALWAYS: 158 | self.ui.fanarttv_cdart_use_always.setChecked(True) 159 | elif config.setting["fanarttv_use_cdart"] == OPTION_CDART_NEVER: 160 | self.ui.fanarttv_cdart_use_never.setChecked(True) 161 | elif config.setting["fanarttv_use_cdart"] == OPTION_CDART_NOALBUMART: 162 | self.ui.fanarttv_cdart_use_if_no_albumcover.setChecked(True) 163 | 164 | def save(self): 165 | config.setting["fanarttv_client_key"] = unicode(self.ui.fanarttv_client_key.text()) 166 | if self.ui.fanarttv_cdart_use_always.isChecked(): 167 | config.setting["fanarttv_use_cdart"] = OPTION_CDART_ALWAYS 168 | elif self.ui.fanarttv_cdart_use_never.isChecked(): 169 | config.setting["fanarttv_use_cdart"] = OPTION_CDART_NEVER 170 | elif self.ui.fanarttv_cdart_use_if_no_albumcover.isChecked(): 171 | config.setting["fanarttv_use_cdart"] = OPTION_CDART_NOALBUMART 172 | 173 | 174 | register_cover_art_provider(CoverArtProviderFanartTv) 175 | register_options_page(FanartTvOptionsPage) 176 | -------------------------------------------------------------------------------- /plugins/fanarttv/options_fanarttv.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | FanartTvOptionsPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 340 10 | 317 11 | 12 | 13 | 14 | 15 | 6 16 | 17 | 18 | 9 19 | 20 | 21 | 9 22 | 23 | 24 | 9 25 | 26 | 27 | 9 28 | 29 | 30 | 31 | 32 | fanart.tv cover art 33 | 34 | 35 | 36 | 2 37 | 38 | 39 | 9 40 | 41 | 42 | 9 43 | 44 | 45 | 9 46 | 47 | 48 | 9 49 | 50 | 51 | 52 | 53 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> 54 | <html><head><meta name="qrichtext" content="1" /><style type="text/css"> 55 | p, li { white-space: pre-wrap; } 56 | </style></head><body style=" font-family:'Cantarell'; font-size:10pt; font-weight:400; font-style:normal;"> 57 | <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="http://fanart.tv/"><span style=" text-decoration: underline; color:#0000ff;">fanart.tv</span></a>. If you want to improve the results of this plugin please contribute.</p> 58 | <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> 59 | <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">In order to use this plugin you have to register a personal API key on<br /><a href="https://fanart.tv/get-an-api-key/"><span style=" text-decoration: underline; color:#0000ff;">https://fanart.tv/get-an-api-key/</span></a></p></body></html> 60 | 61 | 62 | Qt::RichText 63 | 64 | 65 | true 66 | 67 | 68 | true 69 | 70 | 71 | 72 | 73 | 74 | 75 | Qt::Vertical 76 | 77 | 78 | QSizePolicy::Expanding 79 | 80 | 81 | 82 | 20 83 | 40 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | Enter your personal API key here: 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | Medium images 105 | 106 | 107 | 108 | 109 | 110 | Always load medium images 111 | 112 | 113 | 114 | 115 | 116 | 117 | Load only if no front cover is available 118 | 119 | 120 | 121 | 122 | 123 | 124 | Never load medium images 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | Qt::Vertical 135 | 136 | 137 | 138 | 20 139 | 40 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | Qt::Vertical 148 | 149 | 150 | 151 | 20 152 | 40 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /plugins/fanarttv/ui_options_fanarttv.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Automatically generated - don't edit. 4 | # Use `python setup.py build_ui` to update it. 5 | 6 | from PyQt4 import QtCore, QtGui 7 | 8 | try: 9 | _fromUtf8 = QtCore.QString.fromUtf8 10 | except AttributeError: 11 | def _fromUtf8(s): 12 | return s 13 | 14 | try: 15 | _encoding = QtGui.QApplication.UnicodeUTF8 16 | def _translate(context, text, disambig): 17 | return QtGui.QApplication.translate(context, text, disambig, _encoding) 18 | except AttributeError: 19 | def _translate(context, text, disambig): 20 | return QtGui.QApplication.translate(context, text, disambig) 21 | 22 | class Ui_FanartTvOptionsPage(object): 23 | def setupUi(self, FanartTvOptionsPage): 24 | FanartTvOptionsPage.setObjectName(_fromUtf8("FanartTvOptionsPage")) 25 | FanartTvOptionsPage.resize(340, 317) 26 | self.vboxlayout = QtGui.QVBoxLayout(FanartTvOptionsPage) 27 | self.vboxlayout.setSpacing(6) 28 | self.vboxlayout.setMargin(9) 29 | self.vboxlayout.setObjectName(_fromUtf8("vboxlayout")) 30 | self.groupBox = QtGui.QGroupBox(FanartTvOptionsPage) 31 | self.groupBox.setObjectName(_fromUtf8("groupBox")) 32 | self.vboxlayout1 = QtGui.QVBoxLayout(self.groupBox) 33 | self.vboxlayout1.setSpacing(2) 34 | self.vboxlayout1.setMargin(9) 35 | self.vboxlayout1.setObjectName(_fromUtf8("vboxlayout1")) 36 | self.label = QtGui.QLabel(self.groupBox) 37 | self.label.setTextFormat(QtCore.Qt.RichText) 38 | self.label.setWordWrap(True) 39 | self.label.setOpenExternalLinks(True) 40 | self.label.setObjectName(_fromUtf8("label")) 41 | self.vboxlayout1.addWidget(self.label) 42 | spacerItem = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) 43 | self.vboxlayout1.addItem(spacerItem) 44 | self.label_2 = QtGui.QLabel(self.groupBox) 45 | self.label_2.setObjectName(_fromUtf8("label_2")) 46 | self.vboxlayout1.addWidget(self.label_2) 47 | self.fanarttv_client_key = QtGui.QLineEdit(self.groupBox) 48 | self.fanarttv_client_key.setObjectName(_fromUtf8("fanarttv_client_key")) 49 | self.vboxlayout1.addWidget(self.fanarttv_client_key) 50 | self.vboxlayout.addWidget(self.groupBox) 51 | self.verticalGroupBox = QtGui.QGroupBox(FanartTvOptionsPage) 52 | self.verticalGroupBox.setObjectName(_fromUtf8("verticalGroupBox")) 53 | self.verticalLayout = QtGui.QVBoxLayout(self.verticalGroupBox) 54 | self.verticalLayout.setObjectName(_fromUtf8("verticalLayout")) 55 | self.fanarttv_cdart_use_always = QtGui.QRadioButton(self.verticalGroupBox) 56 | self.fanarttv_cdart_use_always.setObjectName(_fromUtf8("fanarttv_cdart_use_always")) 57 | self.verticalLayout.addWidget(self.fanarttv_cdart_use_always) 58 | self.fanarttv_cdart_use_if_no_albumcover = QtGui.QRadioButton(self.verticalGroupBox) 59 | self.fanarttv_cdart_use_if_no_albumcover.setObjectName(_fromUtf8("fanarttv_cdart_use_if_no_albumcover")) 60 | self.verticalLayout.addWidget(self.fanarttv_cdart_use_if_no_albumcover) 61 | self.fanarttv_cdart_use_never = QtGui.QRadioButton(self.verticalGroupBox) 62 | self.fanarttv_cdart_use_never.setObjectName(_fromUtf8("fanarttv_cdart_use_never")) 63 | self.verticalLayout.addWidget(self.fanarttv_cdart_use_never) 64 | self.vboxlayout.addWidget(self.verticalGroupBox) 65 | spacerItem1 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) 66 | self.vboxlayout.addItem(spacerItem1) 67 | spacerItem2 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) 68 | self.vboxlayout.addItem(spacerItem2) 69 | 70 | self.retranslateUi(FanartTvOptionsPage) 71 | QtCore.QMetaObject.connectSlotsByName(FanartTvOptionsPage) 72 | 73 | def retranslateUi(self, FanartTvOptionsPage): 74 | self.groupBox.setTitle(_("fanart.tv cover art")) 75 | self.label.setText(_translate("FanartTvOptionsPage", "\n" 76 | "\n" 79 | "

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

\n" 80 | "


\n" 81 | "

In order to use this plugin you have to register a personal API key on
https://fanart.tv/get-an-api-key/

", None)) 82 | self.label_2.setText(_("Enter your personal API key here:")) 83 | self.verticalGroupBox.setTitle(_("Medium images")) 84 | self.fanarttv_cdart_use_always.setText(_("Always load medium images")) 85 | self.fanarttv_cdart_use_if_no_albumcover.setText(_("Load only if no front cover is available")) 86 | self.fanarttv_cdart_use_never.setText(_("Never load medium images")) 87 | 88 | -------------------------------------------------------------------------------- /plugins/featartist/featartist.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'Feat. Artists Removed' 2 | PLUGIN_AUTHOR = 'Lukas Lalinsky, Bryan Toth' 3 | PLUGIN_DESCRIPTION = 'Removes feat. artists from track titles. Substitution is case insensitive.' 4 | PLUGIN_VERSION = "0.3" 5 | PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15", "0.16"] 6 | 7 | from picard.metadata import register_track_metadata_processor 8 | import re 9 | 10 | _feat_re = re.compile(r"\s+\(feat\. [^)]*\)", re.IGNORECASE) 11 | 12 | 13 | def remove_featartists(tagger, metadata, release, track): 14 | metadata["title"] = _feat_re.sub("", metadata["title"]) 15 | 16 | register_track_metadata_processor(remove_featartists) 17 | -------------------------------------------------------------------------------- /plugins/featartistsintitles/featartistsintitles.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'Feat. Artists in Titles' 2 | PLUGIN_AUTHOR = 'Lukas Lalinsky, Michael Wiencek, Bryan Toth, JeromyNix (NobahdiAtoll)' 3 | PLUGIN_DESCRIPTION = 'Move "feat." from artist names to album and track titles. Match is case insensitive.' 4 | PLUGIN_VERSION = "0.4" 5 | PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15", "0.16"] 6 | 7 | from picard.metadata import register_album_metadata_processor, register_track_metadata_processor 8 | import re 9 | 10 | _feat_re = re.compile(r"([\s\S]+) feat\.([\s\S]+)", re.IGNORECASE) 11 | 12 | 13 | def move_album_featartists(tagger, metadata, release): 14 | match = _feat_re.match(metadata["albumartist"]) 15 | if match: 16 | metadata["albumartist"] = match.group(1) 17 | metadata["album"] += " (feat.%s)" % match.group(2) 18 | match = _feat_re.match(metadata["albumartistsort"]) 19 | if match: 20 | metadata["albumartistsort"] = match.group(1) 21 | 22 | 23 | def move_track_featartists(tagger, metadata, release, track): 24 | match = _feat_re.match(metadata["artist"]) 25 | if match: 26 | metadata["artist"] = match.group(1) 27 | metadata["title"] += " (feat.%s)" % match.group(2) 28 | match = _feat_re.match(metadata["artistsort"]) 29 | if match: 30 | metadata["artistsort"] = match.group(1) 31 | 32 | register_album_metadata_processor(move_album_featartists) 33 | register_track_metadata_processor(move_track_featartists) 34 | -------------------------------------------------------------------------------- /plugins/keep/keep.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = "Keep tags" 2 | PLUGIN_AUTHOR = "Wieland Hoffmann" 3 | PLUGIN_DESCRIPTION = """ 4 | Adds a $keep() function to delete all tags except the ones that you want. 5 | Tags beginning with `musicbrainz_` are kept automatically, as are tags 6 | beginning with `_`.""" 7 | 8 | PLUGIN_VERSION = "1.1" 9 | PLUGIN_API_VERSIONS = ["0.15.0", "0.15.1", "0.16.0", "1.0.0", "1.1.0", "1.2.0", 10 | "1.3.0", "2.0"] 11 | PLUGIN_LICENSE = "GPL-2.0 or later" 12 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 13 | 14 | from picard.script import register_script_function 15 | 16 | 17 | def transltag(tag): 18 | if tag.startswith("~"): 19 | return "_" + tag[1:] 20 | return tag 21 | 22 | 23 | @register_script_function 24 | def keep(parser, *keeptags): 25 | tags = list(parser.context.keys()) 26 | for tag in tags: 27 | if (transltag(tag) not in keeptags and 28 | not tag.startswith("musicbrainz_") and 29 | not tag[0] == "~"): 30 | parser.context.pop(tag, None) 31 | return "" 32 | -------------------------------------------------------------------------------- /plugins/lastfm/options_lastfm.ui: -------------------------------------------------------------------------------- 1 | 2 | LastfmOptionsPage 3 | 4 | 5 | 6 | 0 7 | 0 8 | 305 9 | 317 10 | 11 | 12 | 13 | 14 | 9 15 | 16 | 17 | 6 18 | 19 | 20 | 21 | 22 | Last.fm 23 | 24 | 25 | 26 | 9 27 | 28 | 29 | 2 30 | 31 | 32 | 33 | 34 | Use track tags 35 | 36 | 37 | 38 | 39 | 40 | 41 | Use artist tags 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Tags 52 | 53 | 54 | 55 | 9 56 | 57 | 58 | 2 59 | 60 | 61 | 62 | 63 | Ignore tags: 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 0 74 | 75 | 76 | 6 77 | 78 | 79 | 80 | 81 | 82 | 5 83 | 5 84 | 4 85 | 0 86 | 87 | 88 | 89 | Join multiple tags with: 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 5 98 | 0 99 | 1 100 | 0 101 | 102 | 103 | 104 | true 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | / 114 | 115 | 116 | 117 | 118 | , 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 0 129 | 130 | 131 | 6 132 | 133 | 134 | 135 | 136 | 137 | 7 138 | 5 139 | 0 140 | 0 141 | 142 | 143 | 144 | Minimal tag usage: 145 | 146 | 147 | min_tag_usage 148 | 149 | 150 | 151 | 152 | 153 | 154 | % 155 | 156 | 157 | 100 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | Qt::Vertical 170 | 171 | 172 | 173 | 263 174 | 21 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | use_track_tags 183 | ignore_tags 184 | 185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /plugins/lastfm/ui_options_lastfm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Automatically generated - don't edit. 4 | # Use `python setup.py build_ui` to update it. 5 | 6 | from PyQt4 import QtCore, QtGui 7 | 8 | try: 9 | _fromUtf8 = QtCore.QString.fromUtf8 10 | except AttributeError: 11 | _fromUtf8 = lambda s: s 12 | 13 | class Ui_LastfmOptionsPage(object): 14 | def setupUi(self, LastfmOptionsPage): 15 | LastfmOptionsPage.setObjectName(_fromUtf8("LastfmOptionsPage")) 16 | LastfmOptionsPage.resize(305, 317) 17 | self.vboxlayout = QtGui.QVBoxLayout(LastfmOptionsPage) 18 | self.vboxlayout.setMargin(9) 19 | self.vboxlayout.setSpacing(6) 20 | self.vboxlayout.setObjectName(_fromUtf8("vboxlayout")) 21 | self.rename_files = QtGui.QGroupBox(LastfmOptionsPage) 22 | self.rename_files.setObjectName(_fromUtf8("rename_files")) 23 | self.vboxlayout1 = QtGui.QVBoxLayout(self.rename_files) 24 | self.vboxlayout1.setMargin(9) 25 | self.vboxlayout1.setSpacing(2) 26 | self.vboxlayout1.setObjectName(_fromUtf8("vboxlayout1")) 27 | self.use_track_tags = QtGui.QCheckBox(self.rename_files) 28 | self.use_track_tags.setObjectName(_fromUtf8("use_track_tags")) 29 | self.vboxlayout1.addWidget(self.use_track_tags) 30 | self.use_artist_tags = QtGui.QCheckBox(self.rename_files) 31 | self.use_artist_tags.setObjectName(_fromUtf8("use_artist_tags")) 32 | self.vboxlayout1.addWidget(self.use_artist_tags) 33 | self.vboxlayout.addWidget(self.rename_files) 34 | self.rename_files_2 = QtGui.QGroupBox(LastfmOptionsPage) 35 | self.rename_files_2.setObjectName(_fromUtf8("rename_files_2")) 36 | self.vboxlayout2 = QtGui.QVBoxLayout(self.rename_files_2) 37 | self.vboxlayout2.setMargin(9) 38 | self.vboxlayout2.setSpacing(2) 39 | self.vboxlayout2.setObjectName(_fromUtf8("vboxlayout2")) 40 | self.ignore_tags_2 = QtGui.QLabel(self.rename_files_2) 41 | self.ignore_tags_2.setObjectName(_fromUtf8("ignore_tags_2")) 42 | self.vboxlayout2.addWidget(self.ignore_tags_2) 43 | self.ignore_tags = QtGui.QLineEdit(self.rename_files_2) 44 | self.ignore_tags.setObjectName(_fromUtf8("ignore_tags")) 45 | self.vboxlayout2.addWidget(self.ignore_tags) 46 | self.hboxlayout = QtGui.QHBoxLayout() 47 | self.hboxlayout.setMargin(0) 48 | self.hboxlayout.setSpacing(6) 49 | self.hboxlayout.setObjectName(_fromUtf8("hboxlayout")) 50 | self.ignore_tags_4 = QtGui.QLabel(self.rename_files_2) 51 | sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Policy(5), QtGui.QSizePolicy.Policy(5)) 52 | sizePolicy.setHorizontalStretch(4) 53 | sizePolicy.setVerticalStretch(0) 54 | sizePolicy.setHeightForWidth(self.ignore_tags_4.sizePolicy().hasHeightForWidth()) 55 | self.ignore_tags_4.setSizePolicy(sizePolicy) 56 | self.ignore_tags_4.setObjectName(_fromUtf8("ignore_tags_4")) 57 | self.hboxlayout.addWidget(self.ignore_tags_4) 58 | self.join_tags = QtGui.QComboBox(self.rename_files_2) 59 | sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Policy(5), QtGui.QSizePolicy.Policy(0)) 60 | sizePolicy.setHorizontalStretch(1) 61 | sizePolicy.setVerticalStretch(0) 62 | sizePolicy.setHeightForWidth(self.join_tags.sizePolicy().hasHeightForWidth()) 63 | self.join_tags.setSizePolicy(sizePolicy) 64 | self.join_tags.setEditable(True) 65 | self.join_tags.setObjectName(_fromUtf8("join_tags")) 66 | self.join_tags.addItem(_fromUtf8("")) 67 | self.join_tags.setItemText(0, _fromUtf8("")) 68 | self.join_tags.addItem(_fromUtf8("")) 69 | self.join_tags.addItem(_fromUtf8("")) 70 | self.hboxlayout.addWidget(self.join_tags) 71 | self.vboxlayout2.addLayout(self.hboxlayout) 72 | self.hboxlayout1 = QtGui.QHBoxLayout() 73 | self.hboxlayout1.setMargin(0) 74 | self.hboxlayout1.setSpacing(6) 75 | self.hboxlayout1.setObjectName(_fromUtf8("hboxlayout1")) 76 | self.label_4 = QtGui.QLabel(self.rename_files_2) 77 | sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Policy(7), QtGui.QSizePolicy.Policy(5)) 78 | sizePolicy.setHorizontalStretch(0) 79 | sizePolicy.setVerticalStretch(0) 80 | sizePolicy.setHeightForWidth(self.label_4.sizePolicy().hasHeightForWidth()) 81 | self.label_4.setSizePolicy(sizePolicy) 82 | self.label_4.setObjectName(_fromUtf8("label_4")) 83 | self.hboxlayout1.addWidget(self.label_4) 84 | self.min_tag_usage = QtGui.QSpinBox(self.rename_files_2) 85 | self.min_tag_usage.setMaximum(100) 86 | self.min_tag_usage.setObjectName(_fromUtf8("min_tag_usage")) 87 | self.hboxlayout1.addWidget(self.min_tag_usage) 88 | self.vboxlayout2.addLayout(self.hboxlayout1) 89 | self.vboxlayout.addWidget(self.rename_files_2) 90 | spacerItem = QtGui.QSpacerItem(263, 21, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) 91 | self.vboxlayout.addItem(spacerItem) 92 | self.label_4.setBuddy(self.min_tag_usage) 93 | 94 | self.retranslateUi(LastfmOptionsPage) 95 | QtCore.QMetaObject.connectSlotsByName(LastfmOptionsPage) 96 | LastfmOptionsPage.setTabOrder(self.use_track_tags, self.ignore_tags) 97 | 98 | def retranslateUi(self, LastfmOptionsPage): 99 | self.rename_files.setTitle(_("Last.fm")) 100 | self.use_track_tags.setText(_("Use track tags")) 101 | self.use_artist_tags.setText(_("Use artist tags")) 102 | self.rename_files_2.setTitle(_("Tags")) 103 | self.ignore_tags_2.setText(_("Ignore tags:")) 104 | self.ignore_tags_4.setText(_("Join multiple tags with:")) 105 | self.join_tags.setItemText(1, _(" / ")) 106 | self.join_tags.setItemText(2, _(", ")) 107 | self.label_4.setText(_("Minimal tag usage:")) 108 | self.min_tag_usage.setSuffix(_(" %")) 109 | 110 | -------------------------------------------------------------------------------- /plugins/loadasnat/loadasnat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | PLUGIN_NAME = u"Load as non-album track" 4 | PLUGIN_AUTHOR = u"Philipp Wolfer" 5 | PLUGIN_DESCRIPTION = "Allows loading selected tracks as non-album tracks. Useful for tagging single tracks where you do not care about the album." 6 | PLUGIN_VERSION = "0.1" 7 | PLUGIN_API_VERSIONS = ["1.4.0"] 8 | PLUGIN_LICENSE = "GPL-2.0" 9 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 10 | 11 | from picard import log 12 | from picard.album import Track 13 | from picard.ui.itemviews import BaseAction, register_track_action 14 | 15 | class LoadAsNat(BaseAction): 16 | NAME = "Load as non-album track..." 17 | 18 | def callback(self, objs): 19 | tracks = [t for t in objs if isinstance(t, Track)] 20 | 21 | if len(tracks) == 0: 22 | return 23 | 24 | for track in tracks: 25 | nat = self.tagger.load_nat(track.metadata['musicbrainz_recordingid']) 26 | for file in track.iterfiles(): 27 | file.move(nat) 28 | file.metadata.delete('albumartist') 29 | file.metadata.delete('albumartistsort') 30 | file.metadata.delete('albumsort') 31 | file.metadata.delete('asin') 32 | file.metadata.delete('barcode') 33 | file.metadata.delete('catalognumber') 34 | file.metadata.delete('discnumber') 35 | file.metadata.delete('discsubtitle') 36 | file.metadata.delete('media') 37 | file.metadata.delete('musicbrainz_albumartistid') 38 | file.metadata.delete('musicbrainz_albumid') 39 | file.metadata.delete('musicbrainz_discid') 40 | file.metadata.delete('musicbrainz_releasegroupid') 41 | file.metadata.delete('releasecountry') 42 | file.metadata.delete('releasestatus') 43 | file.metadata.delete('releasetype') 44 | file.metadata.delete('totaldiscs') 45 | file.metadata.delete('totaltracks') 46 | file.metadata.delete('tracknumber') 47 | log.debug("[LoadAsNat] deleted tags: %r", file.metadata.deleted_tags) 48 | 49 | 50 | register_track_action(LoadAsNat()) 51 | -------------------------------------------------------------------------------- /plugins/moodbars/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Changelog: 4 | # [2015-09-24] Initial version with support for Ogg Vorbis, FLAC, WAV and MP3, tested MP3 and FLAC 5 | 6 | PLUGIN_NAME = u"Moodbars" 7 | PLUGIN_AUTHOR = u"Len Joubert" 8 | PLUGIN_DESCRIPTION = """Calculate Moodbars for selected files and albums.""" 9 | PLUGIN_LICENSE = "GPL-2.0" 10 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 11 | PLUGIN_VERSION = "0.1" 12 | PLUGIN_API_VERSIONS = ["0.10", "0.15", "0.16"] 13 | #PLUGIN_INCOMPATIBLE_PLATFORMS = [ 14 | # 'win32', 'cygwyn', 'darwin', 'os2', 'os2emx', 'riscos', 'atheos'] 15 | 16 | import os.path 17 | from collections import defaultdict 18 | from subprocess import check_call 19 | from picard.album import Album, NatAlbum 20 | from picard.track import Track 21 | from picard.file import File 22 | from picard.util import encode_filename, decode_filename, partial, thread 23 | from picard.ui.options import register_options_page, OptionsPage 24 | from picard.config import TextOption 25 | from picard.ui.itemviews import (BaseAction, register_file_action, 26 | register_album_action) 27 | from picard.plugins.moodbars.ui_options_moodbar import Ui_MoodbarOptionsPage 28 | 29 | # Path to various moodbar tools. There must be a tool for every supported 30 | # audio file format. 31 | MOODBAR_COMMANDS = { 32 | "Ogg Vorbis": ("moodbar_vorbis_command", "moodbar_vorbis_options"), 33 | "MPEG-1 Audio": ("moodbar_mp3_command", "moodbar_mp3_options"), 34 | "FLAC": ("moodbar_flac_command", "moodbar_flac_options"), 35 | "WavPack": ("moodbar_wav_command", "moodbar_wav_options"), 36 | } 37 | 38 | 39 | def generate_moodbar_for_files(files, format, tagger): 40 | """Generate the moodfiles for a list of files in album mode.""" 41 | file_list = ['%s' % encode_filename(f.filename) for f in files] 42 | for mood_file in file_list: 43 | new_filename = os.path.join(os.path.dirname( 44 | mood_file), '.' + os.path.splitext(os.path.basename(mood_file))[0] + '.mood') 45 | # file format to make it compaitble with Amarok and hidden in linux 46 | file_list_mood = ['%s' % new_filename] 47 | 48 | if format in MOODBAR_COMMANDS \ 49 | and tagger.config.setting[MOODBAR_COMMANDS[format][0]]: 50 | command = tagger.config.setting[MOODBAR_COMMANDS[format][0]] 51 | options = tagger.config.setting[ 52 | MOODBAR_COMMANDS[format][1]].split(' ') 53 | # tagger.log.debug('My debug >>> %s' % (file_list_mood)) 54 | tagger.log.debug( 55 | '%s %s %s %s' % (command, decode_filename(' '.join(file_list)), ' '.join(options), decode_filename(' '.join(file_list_mood)))) 56 | check_call([command] + file_list + options + file_list_mood) 57 | else: 58 | raise Exception('Moodbar: Unsupported format %s' % (format)) 59 | 60 | 61 | class MoodBar(BaseAction): 62 | NAME = N_("Generate Moodbar &file...") 63 | 64 | def _add_file_to_queue(self, file): 65 | thread.run_task( 66 | partial(self._generate_moodbar, file), 67 | partial(self._moodbar_callback, file)) 68 | 69 | def callback(self, objs): 70 | for obj in objs: 71 | if isinstance(obj, Track): 72 | for file_ in obj.linked_files: 73 | self._add_file_to_queue(file_) 74 | elif isinstance(obj, File): 75 | self._add_file_to_queue(obj) 76 | 77 | def _generate_moodbar(self, file): 78 | self.tagger.window.set_statusbar_message( 79 | N_('Calculating moodbar for "%(filename)s"...'), 80 | {'filename': file.filename} 81 | ) 82 | generate_moodbar_for_files([file], file.NAME, self.tagger) 83 | 84 | def _moodbar_callback(self, file, result=None, error=None): 85 | if not error: 86 | self.tagger.window.set_statusbar_message( 87 | N_('Moodbar for "%(filename)s" successfully generated.'), 88 | {'filename': file.filename} 89 | ) 90 | else: 91 | self.tagger.window.set_statusbar_message( 92 | N_('Could not generate moodbar for "%(filename)s".'), 93 | {'filename': file.filename} 94 | ) 95 | 96 | 97 | class MoodbarOptionsPage(OptionsPage): 98 | 99 | NAME = "Moodbars" 100 | TITLE = "Moodbars" 101 | PARENT = "plugins" 102 | 103 | options = [ 104 | TextOption("setting", "moodbar_vorbis_command", "moodbar"), 105 | TextOption("setting", "moodbar_vorbis_options", "-o"), 106 | TextOption("setting", "moodbar_mp3_command", "moodbar"), 107 | TextOption("setting", "moodbar_mp3_options", "-o"), 108 | TextOption("setting", "moodbar_flac_command", "moodbar"), 109 | TextOption("setting", "moodbar_flac_options", "-o"), 110 | TextOption("setting", "moodbar_wav_command", "moodbar"), 111 | TextOption("setting", "moodbar_wav_options", "-o") 112 | ] 113 | 114 | def __init__(self, parent=None): 115 | super(MoodbarOptionsPage, self).__init__(parent) 116 | self.ui = Ui_MoodbarOptionsPage() 117 | self.ui.setupUi(self) 118 | 119 | def load(self): 120 | self.ui.vorbis_command.setText( 121 | self.config.setting["moodbar_vorbis_command"]) 122 | self.ui.mp3_command.setText( 123 | self.config.setting["moodbar_mp3_command"]) 124 | self.ui.flac_command.setText( 125 | self.config.setting["moodbar_flac_command"]) 126 | self.ui.wav_command.setText( 127 | self.config.setting["moodbar_wav_command"]) 128 | 129 | def save(self): 130 | self.config.setting["moodbar_vorbis_command"] = unicode( 131 | self.ui.vorbis_command.text()) 132 | self.config.setting["moodbar_mp3_command"] = unicode( 133 | self.ui.mp3_command.text()) 134 | self.config.setting["moodbar_flac_command"] = unicode( 135 | self.ui.flac_command.text()) 136 | self.config.setting["moodbar_wav_command"] = unicode( 137 | self.ui.wav_command.text()) 138 | 139 | register_file_action(MoodBar()) 140 | register_options_page(MoodbarOptionsPage) 141 | -------------------------------------------------------------------------------- /plugins/moodbars/options_moodbar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MoodbarOptionsPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 305 10 | 317 11 | 12 | 13 | 14 | 15 | 6 16 | 17 | 18 | 9 19 | 20 | 21 | 22 | 23 | Moodbar 24 | 25 | 26 | 27 | 2 28 | 29 | 30 | 9 31 | 32 | 33 | 34 | 35 | Path to Ogg Vorbis moodbar tool: 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | Path to MP3 moodbar tool: 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Path to FLAC moodbar tool: 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | Path to WAV moodbar tool: 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | Qt::Vertical 79 | 80 | 81 | 82 | 263 83 | 21 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /plugins/moodbars/ui_options_moodbar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'options_moodbar.ui' 4 | # 5 | # Created: Thu Sep 24 14:14:45 2015 6 | # by: PyQt4 UI code generator 4.11.3 7 | # 8 | # WARNING! All changes made in this file will be lost! 9 | 10 | from PyQt4 import QtCore, QtGui 11 | 12 | try: 13 | _fromUtf8 = QtCore.QString.fromUtf8 14 | except AttributeError: 15 | def _fromUtf8(s): 16 | return s 17 | 18 | try: 19 | _encoding = QtGui.QApplication.UnicodeUTF8 20 | def _translate(context, text, disambig): 21 | return QtGui.QApplication.translate(context, text, disambig, _encoding) 22 | except AttributeError: 23 | def _translate(context, text, disambig): 24 | return QtGui.QApplication.translate(context, text, disambig) 25 | 26 | class Ui_MoodbarOptionsPage(object): 27 | def setupUi(self, MoodbarOptionsPage): 28 | MoodbarOptionsPage.setObjectName(_fromUtf8("MoodbarOptionsPage")) 29 | MoodbarOptionsPage.resize(305, 317) 30 | self.vboxlayout = QtGui.QVBoxLayout(MoodbarOptionsPage) 31 | self.vboxlayout.setSpacing(6) 32 | self.vboxlayout.setMargin(9) 33 | self.vboxlayout.setObjectName(_fromUtf8("vboxlayout")) 34 | self.moodbar_group = QtGui.QGroupBox(MoodbarOptionsPage) 35 | self.moodbar_group.setObjectName(_fromUtf8("moodbar_group")) 36 | self.vboxlayout1 = QtGui.QVBoxLayout(self.moodbar_group) 37 | self.vboxlayout1.setSpacing(2) 38 | self.vboxlayout1.setMargin(9) 39 | self.vboxlayout1.setObjectName(_fromUtf8("vboxlayout1")) 40 | self.label = QtGui.QLabel(self.moodbar_group) 41 | self.label.setObjectName(_fromUtf8("label")) 42 | self.vboxlayout1.addWidget(self.label) 43 | self.vorbis_command = QtGui.QLineEdit(self.moodbar_group) 44 | self.vorbis_command.setObjectName(_fromUtf8("vorbis_command")) 45 | self.vboxlayout1.addWidget(self.vorbis_command) 46 | self.label_2 = QtGui.QLabel(self.moodbar_group) 47 | self.label_2.setObjectName(_fromUtf8("label_2")) 48 | self.vboxlayout1.addWidget(self.label_2) 49 | self.mp3_command = QtGui.QLineEdit(self.moodbar_group) 50 | self.mp3_command.setObjectName(_fromUtf8("mp3_command")) 51 | self.vboxlayout1.addWidget(self.mp3_command) 52 | self.label_3 = QtGui.QLabel(self.moodbar_group) 53 | self.label_3.setObjectName(_fromUtf8("label_3")) 54 | self.vboxlayout1.addWidget(self.label_3) 55 | self.flac_command = QtGui.QLineEdit(self.moodbar_group) 56 | self.flac_command.setObjectName(_fromUtf8("flac_command")) 57 | self.vboxlayout1.addWidget(self.flac_command) 58 | self.label_4 = QtGui.QLabel(self.moodbar_group) 59 | self.label_4.setObjectName(_fromUtf8("label_4")) 60 | self.vboxlayout1.addWidget(self.label_4) 61 | self.wav_command = QtGui.QLineEdit(self.moodbar_group) 62 | self.wav_command.setObjectName(_fromUtf8("wav_command")) 63 | self.vboxlayout1.addWidget(self.wav_command) 64 | self.vboxlayout.addWidget(self.moodbar_group) 65 | spacerItem = QtGui.QSpacerItem(263, 21, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) 66 | self.vboxlayout.addItem(spacerItem) 67 | 68 | self.retranslateUi(MoodbarOptionsPage) 69 | QtCore.QMetaObject.connectSlotsByName(MoodbarOptionsPage) 70 | 71 | def retranslateUi(self, MoodbarOptionsPage): 72 | self.moodbar_group.setTitle(_translate("MoodbarOptionsPage", "Moodbar", None)) 73 | self.label.setText(_translate("MoodbarOptionsPage", "Path to Ogg Vorbis moodbar tool:", None)) 74 | self.label_2.setText(_translate("MoodbarOptionsPage", "Path to MP3 moodbar tool:", None)) 75 | self.label_3.setText(_translate("MoodbarOptionsPage", "Path to FLAC moodbar tool:", None)) 76 | self.label_4.setText(_translate("MoodbarOptionsPage", "Path to WAV moodbar tool:", None)) 77 | 78 | -------------------------------------------------------------------------------- /plugins/musixmatch/README: -------------------------------------------------------------------------------- 1 | Installation: 2 | Obtain API key from Musixmatch (https://developer.musixmatch.com) 3 | -------------------------------------------------------------------------------- /plugins/musixmatch/__init__.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'Musixmatch Lyrics' 2 | PLUGIN_AUTHOR = 'm-yn' 3 | PLUGIN_DESCRIPTION = 'Fetch first 30% of lyrics from Musixmatch' 4 | PLUGIN_VERSION = '0.2' 5 | PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15"] 6 | PLUGIN_LICENSE = "GPL-2.0" 7 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 8 | 9 | 10 | from picard.metadata import register_track_metadata_processor 11 | from picard.ui.options import register_options_page, OptionsPage 12 | from picard.config import TextOption 13 | from ui_options_musixmatch import Ui_MusixmatchOptionsPage 14 | 15 | 16 | class MusixmatchOptionsPage(OptionsPage): 17 | NAME = 'musixmatch' 18 | TITLE = 'Musixmatch API Key' 19 | PARENT = "plugins" 20 | options = [ 21 | TextOption("setting", "musixmatch_api_key", "") 22 | ] 23 | 24 | def __init__(self, parent=None): 25 | super(MusixmatchOptionsPage, self).__init__(parent) 26 | self.ui = Ui_MusixmatchOptionsPage() 27 | self.ui.setupUi(self) 28 | 29 | def load(self): 30 | self.ui.api_key.setText(self.config.setting["musixmatch_api_key"]) 31 | 32 | def save(self): 33 | self.config.setting["musixmatch_api_key"] = self.ui.api_key.text() 34 | register_options_page(MusixmatchOptionsPage) 35 | 36 | import picard.tagger as tagger 37 | import os 38 | try: 39 | os.environ['MUSIXMATCH_API_KEY'] = tagger.config.setting[ 40 | "musixmatch_api_key"] 41 | except: 42 | pass 43 | 44 | from musixmatch import track as TRACK 45 | 46 | 47 | def process_track(album, metadata, release, track): 48 | if('MUSIXMATCH_API_KEY' not in os.environ): 49 | return 50 | try: 51 | t = TRACK.Track(metadata['musicbrainz_trackid'], 52 | musicbrainz=True).lyrics() 53 | if t['instrumental'] == 1: 54 | lyrics = "[Instrumental]" 55 | else: 56 | lyrics = t['lyrics_body'] 57 | metadata['lyrics:description'] = lyrics 58 | except Exception as e: 59 | pass 60 | 61 | register_track_metadata_processor(process_track) 62 | -------------------------------------------------------------------------------- /plugins/musixmatch/musixmatch/__init__.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'Musixmatch Lyrics' 2 | PLUGIN_AUTHOR = 'm-yn' 3 | PLUGIN_DESCRIPTION = 'Fetch first 30% of lyrics from Musixmatch' 4 | PLUGIN_VERSION = '0.2' 5 | PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15"] 6 | PLUGIN_LICENSE = "GPL-2.0" 7 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 8 | -------------------------------------------------------------------------------- /plugins/musixmatch/ui_options_musixmatch.py: -------------------------------------------------------------------------------- 1 | from PyQt4 import QtCore, QtGui 2 | 3 | try: 4 | _fromUtf8 = QtCore.QString.fromUtf8 5 | except AttributeError: 6 | _fromUtf8 = lambda s: s 7 | 8 | class Ui_MusixmatchOptionsPage(object): 9 | def setupUi(self, MusixmatchOptionsPage): 10 | MusixmatchOptionsPage.setObjectName(_fromUtf8("MusixmatchOptionsPage")) 11 | MusixmatchOptionsPage.resize(305, 317) 12 | self.vboxlayout = QtGui.QVBoxLayout(MusixmatchOptionsPage) 13 | self.vboxlayout.setMargin(9) 14 | self.vboxlayout.setSpacing(6) 15 | self.vboxlayout.setObjectName(_fromUtf8("vboxlayout")) 16 | self.api_key_group = QtGui.QGroupBox(MusixmatchOptionsPage) 17 | self.api_key_group.setObjectName(_fromUtf8("api_key_group")) 18 | self.vboxlayout2 = QtGui.QVBoxLayout(self.api_key_group) 19 | self.vboxlayout2.setMargin(9) 20 | self.vboxlayout2.setSpacing(2) 21 | self.vboxlayout2.setObjectName(_fromUtf8("vboxlayout2")) 22 | self.api_key_label = QtGui.QLabel(self.api_key_group) 23 | self.api_key_label.setObjectName(_fromUtf8("api_key_label")) 24 | self.vboxlayout2.addWidget(self.api_key_label) 25 | self.api_key = QtGui.QLineEdit(self.api_key_group) 26 | self.api_key.setObjectName(_fromUtf8("api_key")) 27 | self.vboxlayout2.addWidget(self.api_key) 28 | self.vboxlayout.addWidget(self.api_key_group) 29 | spacerItem = QtGui.QSpacerItem(263, 21, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) 30 | self.vboxlayout.addItem(spacerItem) 31 | 32 | self.retranslateUi(MusixmatchOptionsPage) 33 | QtCore.QMetaObject.connectSlotsByName(MusixmatchOptionsPage) 34 | 35 | 36 | 37 | 38 | def retranslateUi(self, MusixmatchOptionsPage): 39 | self.api_key_label.setText(_("Musixmatch API Key")) 40 | -------------------------------------------------------------------------------- /plugins/no_release/no_release.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | PLUGIN_NAME = u'No release' 4 | PLUGIN_AUTHOR = u'Johannes Weißl' 5 | PLUGIN_DESCRIPTION = '''Do not store specific release information in releases of unknown origin.''' 6 | PLUGIN_VERSION = '0.1' 7 | PLUGIN_API_VERSIONS = ['0.15'] 8 | 9 | from PyQt4 import QtCore, QtGui 10 | 11 | from picard.album import Album 12 | from picard.metadata import register_album_metadata_processor, register_track_metadata_processor 13 | from picard.ui.options import register_options_page, OptionsPage 14 | from picard.ui.itemviews import BaseAction, register_album_action 15 | from picard.config import BoolOption, TextOption 16 | 17 | 18 | class Ui_NoReleaseOptionsPage(object): 19 | 20 | def setupUi(self, NoReleaseOptionsPage): 21 | NoReleaseOptionsPage.setObjectName('NoReleaseOptionsPage') 22 | NoReleaseOptionsPage.resize(394, 300) 23 | self.verticalLayout = QtGui.QVBoxLayout(NoReleaseOptionsPage) 24 | self.verticalLayout.setObjectName('verticalLayout') 25 | self.groupBox = QtGui.QGroupBox(NoReleaseOptionsPage) 26 | self.groupBox.setObjectName('groupBox') 27 | self.vboxlayout = QtGui.QVBoxLayout(self.groupBox) 28 | self.vboxlayout.setObjectName('vboxlayout') 29 | self.norelease_enable = QtGui.QCheckBox(self.groupBox) 30 | self.norelease_enable.setObjectName('norelease_enable') 31 | self.vboxlayout.addWidget(self.norelease_enable) 32 | self.label = QtGui.QLabel(self.groupBox) 33 | self.label.setObjectName('label') 34 | self.vboxlayout.addWidget(self.label) 35 | self.horizontalLayout = QtGui.QHBoxLayout() 36 | self.horizontalLayout.setObjectName('horizontalLayout') 37 | self.norelease_strip_tags = QtGui.QLineEdit(self.groupBox) 38 | self.norelease_strip_tags.setObjectName('norelease_strip_tags') 39 | self.horizontalLayout.addWidget(self.norelease_strip_tags) 40 | self.vboxlayout.addLayout(self.horizontalLayout) 41 | self.verticalLayout.addWidget(self.groupBox) 42 | spacerItem = QtGui.QSpacerItem(368, 187, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) 43 | self.verticalLayout.addItem(spacerItem) 44 | 45 | self.retranslateUi(NoReleaseOptionsPage) 46 | QtCore.QMetaObject.connectSlotsByName(NoReleaseOptionsPage) 47 | 48 | def retranslateUi(self, NoReleaseOptionsPage): 49 | self.groupBox.setTitle(QtGui.QApplication.translate('NoReleaseOptionsPage', 'No release', None, QtGui.QApplication.UnicodeUTF8)) 50 | self.norelease_enable.setText(QtGui.QApplication.translate('NoReleaseOptionsPage', _('Enable plugin for all releases by default'), None, QtGui.QApplication.UnicodeUTF8)) 51 | self.label.setText(QtGui.QApplication.translate('NoReleaseOptionsPage', _('Tags to strip (comma-separated)'), None, QtGui.QApplication.UnicodeUTF8)) 52 | 53 | 54 | def strip_release_specific_metadata(tagger, metadata): 55 | strip_tags = tagger.config.setting['norelease_strip_tags'] 56 | strip_tags = [tag.strip() for tag in strip_tags.split(',')] 57 | for tag in strip_tags: 58 | if tag in metadata: 59 | del metadata[tag] 60 | 61 | 62 | class NoReleaseAction(BaseAction): 63 | NAME = _('Remove specific release information...') 64 | 65 | def callback(self, objs): 66 | for album in objs: 67 | if isinstance(album, Album): 68 | strip_release_specific_metadata(self.tagger, album.metadata) 69 | for track in album.tracks: 70 | strip_release_specific_metadata(self.tagger, track.metadata) 71 | for file in track.linked_files: 72 | track.update_file_metadata(file) 73 | album.update() 74 | 75 | 76 | class NoReleaseOptionsPage(OptionsPage): 77 | NAME = 'norelease' 78 | TITLE = 'No release' 79 | PARENT = 'plugins' 80 | 81 | options = [ 82 | BoolOption('setting', 'norelease_enable', False), 83 | TextOption('setting', 'norelease_strip_tags', 'asin,barcode,catalognumber,date,label,media,releasecountry,releasestatus'), 84 | ] 85 | 86 | def __init__(self, parent=None): 87 | super(NoReleaseOptionsPage, self).__init__(parent) 88 | self.ui = Ui_NoReleaseOptionsPage() 89 | self.ui.setupUi(self) 90 | 91 | def load(self): 92 | self.ui.norelease_strip_tags.setText(self.config.setting['norelease_strip_tags']) 93 | self.ui.norelease_enable.setChecked(self.config.setting['norelease_enable']) 94 | 95 | def save(self): 96 | self.config.setting['norelease_strip_tags'] = unicode(self.ui.norelease_strip_tags.text()) 97 | self.config.setting['norelease_enable'] = self.ui.norelease_enable.isChecked() 98 | 99 | 100 | def NoReleaseAlbumProcessor(tagger, metadata, release): 101 | if tagger.config.setting['norelease_enable']: 102 | strip_release_specific_metadata(tagger, metadata) 103 | 104 | 105 | def NoReleaseTrackProcessor(tagger, metadata, track, release): 106 | if tagger.config.setting['norelease_enable']: 107 | strip_release_specific_metadata(tagger, metadata) 108 | 109 | register_album_metadata_processor(NoReleaseAlbumProcessor) 110 | register_track_metadata_processor(NoReleaseTrackProcessor) 111 | register_album_action(NoReleaseAction()) 112 | register_options_page(NoReleaseOptionsPage) 113 | -------------------------------------------------------------------------------- /plugins/non_ascii_equivalents/non_ascii_equivalents.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (C) 2016 Anderson Mesquita 4 | # 5 | # This program is free software: you can redistribute it and/or modify it under 6 | # the terms of the GNU General Public License as published by the Free Software 7 | # Foundation, either version 3 of the License, or (at your option) any later 8 | # version. 9 | # 10 | # This program is distributed in the hope that it will be useful, but WITHOUT 11 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 12 | # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | # details. 14 | # 15 | # You should have received a copy of the GNU General Public License along with 16 | # this program. If not, see . 17 | 18 | from picard import metadata 19 | 20 | PLUGIN_NAME = "Non-ASCII Equivalents" 21 | PLUGIN_AUTHOR = "Anderson Mesquita " 22 | PLUGIN_VERSION = "0.1" 23 | PLUGIN_API_VERSIONS = ["0.9", "0.10", "0.11", "0.15"] 24 | PLUGIN_LICENSE = "GPLv3" 25 | PLUGIN_LICENSE_URL = "https://gnu.org/licenses/gpl.html" 26 | PLUGIN_DESCRIPTION = '''Replaces accented and otherwise non-ASCII characters 27 | with a somewhat equivalent version of their ASCII counterparts. This allows old 28 | devices to be able to display song artists and titles somewhat correctly, 29 | instead of displaying weird or blank symbols. It's an attempt to do a little 30 | better than Musicbrainz's native "Replace non-ASCII characters" option.''' 31 | 32 | CHAR_TABLE = { 33 | # Acute # Grave # Umlaut # Circumflex 34 | u"Á": "A", u"À": "A", u"Ä": "A", u"Â": "A", 35 | u"É": "E", u"È": "E", u"Ë": "E", u"Ê": "E", 36 | u"Í": "I", u"Ì": "I", u"Ï": "I", u"Î": "I", 37 | u"Ó": "O", u"Ò": "O", u"Ö": "O", u"Ô": "O", 38 | u"Ú": "U", u"Ù": "U", u"Ü": "U", u"Û": "U", 39 | u"Ý": "Y", u"Ỳ": "Y", u"Ÿ": "Y", u"Ŷ": "Y", 40 | u"á": "a", u"à": "a", u"ä": "a", u"â": "a", 41 | u"é": "e", u"è": "e", u"ë": "e", u"ê": "e", 42 | u"í": "i", u"ì": "i", u"ï": "i", u"î": "i", 43 | u"ó": "o", u"ò": "o", u"ö": "o", u"ô": "o", 44 | u"ú": "u", u"ù": "u", u"ü": "u", u"û": "u", 45 | u"ý": "y", u"ỳ": "y", u"ÿ": "y", u"ŷ": "y", 46 | 47 | # Misc Letters 48 | u"Å": "AA", 49 | u"å": "aa", 50 | u"Æ": "AE", 51 | u"æ": "ae", 52 | u"Œ": "OE", 53 | u"œ": "oe", 54 | u"ẞ": "ss", 55 | u"ß": "ss", 56 | u"Ç": "C", 57 | u"ç": "c", 58 | u"Ñ": "N", 59 | u"ñ": "n", 60 | u"Ø": "O", 61 | u"ø": "o", 62 | 63 | # Punctuation 64 | u"¡": "!", 65 | u"¿": "?", 66 | u"–": "--", 67 | u"—": "--", 68 | u"―": "--", 69 | u"«": "<<", 70 | u"»": ">>", 71 | u"‘": "'", 72 | u"’": "'", 73 | u"‚": ",", 74 | u"‛": "'", 75 | u"“": '"', 76 | u"”": '"', 77 | u"„": ",,", 78 | u"‟": '"', 79 | u"‹": "<", 80 | u"›": ">", 81 | u"⹂": ",,", 82 | u"「": "|-", 83 | u"」": "-|", 84 | u"『": "|-", 85 | u"』": "-|", 86 | u"〝": '"', 87 | u"〞": '"', 88 | u"〟": ",,", 89 | u"﹁": "-|", 90 | u"﹂": "|-", 91 | u"﹃": "-|", 92 | u"﹄": "|-", 93 | u""": '"', 94 | u"'": "'", 95 | u"「": "|-", 96 | u"」": "-|", 97 | 98 | # Mathematics 99 | u"≠": "!=", 100 | u"≤": "<=", 101 | u"≥": ">=", 102 | u"±": "+-", 103 | u"∓": "-+", 104 | u"×": "x", 105 | u"·": ".", 106 | u"÷": "/", 107 | u"√": "\/", 108 | u"∑": "E", 109 | u"≪": "<<", # these are different 110 | u"≫": ">>", # from the quotation marks 111 | 112 | # Misc 113 | u"ª": "a", 114 | u"º": "o", 115 | u"°": "o", 116 | u"µ": "u", 117 | u"ı": "i", 118 | u"†": "t", 119 | u"©": "(c)", 120 | u"®": "(R)", 121 | u"℠": "(SM)", 122 | u"™": "(TM)", 123 | } 124 | 125 | FILTER_TAGS = [ 126 | "album", 127 | "artist", 128 | "title", 129 | ] 130 | 131 | 132 | def sanitize(char): 133 | if char in CHAR_TABLE: 134 | return CHAR_TABLE[char] 135 | return char 136 | 137 | 138 | def ascii(word): 139 | return "".join(sanitize(char) for char in word) 140 | 141 | 142 | def main(tagger, metadata, release, track=None): 143 | for name, value in metadata.rawitems(): 144 | if name in FILTER_TAGS: 145 | metadata[name] = [ascii(x) for x in value] 146 | 147 | 148 | metadata.register_track_metadata_processor(main) 149 | metadata.register_album_metadata_processor(main) 150 | -------------------------------------------------------------------------------- /plugins/padded/padded.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = "Padded disc and tracknumbers" 2 | PLUGIN_AUTHOR = "Wieland Hoffmann" 3 | PLUGIN_DESCRIPTION = """ 4 | Adds padded disc- and tracknumbers so the length of all disc- and tracknumbers 5 | is the same. They are stored in the `_paddedtracknumber` and `_paddeddiscnumber` 6 | tags.""" 7 | 8 | PLUGIN_VERSION = "1.0" 9 | PLUGIN_API_VERSIONS = ["0.15.0", "0.15.1", "0.16.0", "1.0.0", "1.1.0", "1.2.0", 10 | "1.3.0", "2.0", ] 11 | PLUGIN_LICENSE = "GPL-2.0 or later" 12 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 13 | 14 | from picard.metadata import register_track_metadata_processor 15 | 16 | 17 | @register_track_metadata_processor 18 | def add_padded_tn(album, metadata, release, track): 19 | maxlength = len(metadata["totaltracks"]) 20 | islength = len(metadata["tracknumber"]) 21 | metadata["~paddedtracknumber"] = (int(maxlength - islength) * "0" + 22 | metadata["tracknumber"]) 23 | 24 | 25 | @register_track_metadata_processor 26 | def add_padded_dn(album, metadata, release, track): 27 | maxlength = len(metadata["totaldiscs"]) 28 | islength = len(metadata["discnumber"]) 29 | metadata["~paddeddiscnumber"] = (int(maxlength - islength) * "0" + 30 | metadata["discnumber"]) 31 | -------------------------------------------------------------------------------- /plugins/papercdcase/papercdcase.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Philipp Wolfer 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License 7 | # as published by the Free Software Foundation; either version 2 8 | # of the License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software 17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 18 | # 02110-1301, USA. 19 | 20 | PLUGIN_NAME = u'Paper CD case' 21 | PLUGIN_AUTHOR = u'Philipp Wolfer' 22 | PLUGIN_DESCRIPTION = u'Create a paper CD case from an album or cluster using http://papercdcase.com' 23 | PLUGIN_VERSION = "0.1" 24 | PLUGIN_API_VERSIONS = ["1.3.0", "1.4.0"] 25 | PLUGIN_LICENSE = "GPL-2.0" 26 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 27 | 28 | 29 | from PyQt4.QtCore import QUrl 30 | from picard.album import Album 31 | from picard.cluster import Cluster 32 | from picard.ui.itemviews import BaseAction, register_album_action, register_cluster_action 33 | from picard.util import webbrowser2 34 | from picard.util import textencoding 35 | 36 | 37 | PAPERCDCASE_URL = 'http://papercdcase.com/advanced.php' 38 | 39 | 40 | def build_papercdcase_url(artist, album, tracks): 41 | url = QUrl(PAPERCDCASE_URL) 42 | # papercdcase.com does not deal well with unicode characters :( 43 | url.addQueryItem('artist', textencoding.asciipunct(artist)) 44 | url.addQueryItem('title', textencoding.asciipunct(album)) 45 | i = 1 46 | for track in tracks: 47 | url.addQueryItem('track' + str(i), textencoding.asciipunct(track)) 48 | i += 1 49 | return url.toString() 50 | 51 | 52 | class PaperCdCase(BaseAction): 53 | NAME = 'Create paper CD case' 54 | 55 | def callback(self, objs): 56 | for obj in objs: 57 | if isinstance(obj, Album) or isinstance(obj, Cluster): 58 | artist = obj.metadata['albumartist'] 59 | album = obj.metadata['album'] 60 | if isinstance(obj, Album): 61 | tracks = [track.metadata['title'] for track in obj.tracks] 62 | else: 63 | tracks = [file.metadata['title'] for file in obj.files] 64 | url = build_papercdcase_url(artist, album, tracks) 65 | webbrowser2.open(url) 66 | 67 | 68 | paperCdCase = PaperCdCase() 69 | register_album_action(paperCdCase) 70 | register_cluster_action(paperCdCase) 71 | -------------------------------------------------------------------------------- /plugins/playlist/playlist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | 14 | PLUGIN_NAME = u"Generate M3U playlist" 15 | PLUGIN_AUTHOR = u"Francis Chin" 16 | PLUGIN_DESCRIPTION = u"""Generate an Extended M3U playlist (.m3u8 file, UTF8 17 | encoded text). Relative pathnames are used where audio files are in the same 18 | directory as the playlist, otherwise absolute (full) pathnames are used.""" 19 | PLUGIN_VERSION = "0.3" 20 | PLUGIN_API_VERSIONS = ["1.0.0", "1.1.0", "1.2.0", "1.3.0"] 21 | PLUGIN_LICENSE = "GPL-2.0" 22 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 23 | 24 | import os.path 25 | 26 | from PyQt4 import QtCore, QtGui 27 | from picard import log 28 | from picard.const import VARIOUS_ARTISTS_ID 29 | from picard.util import find_existing_path, encode_filename 30 | from picard.ui.itemviews import BaseAction, register_album_action 31 | 32 | 33 | _debug_level = 0 34 | 35 | def get_safe_filename(filename): 36 | _valid_chars = u" .,_-:+&!()" 37 | _safe_filename = u"".join( 38 | c if (c.isalnum() or c in _valid_chars) else "_" for c in filename 39 | ).rstrip() 40 | return _safe_filename 41 | 42 | 43 | class PlaylistEntry(list): 44 | 45 | def __init__(self, playlist, index): 46 | list.__init__(self) 47 | self.playlist = playlist 48 | self.index = index 49 | 50 | def add(self, entry_row): 51 | self.append(entry_row + "\n") 52 | 53 | 54 | class Playlist(object): 55 | 56 | def __init__(self, filename): 57 | self.filename = filename 58 | self.entries = [] 59 | self.headers = [] 60 | 61 | def add_header(self, header): 62 | self.headers.append(header + "\n") 63 | 64 | def write(self): 65 | b_lines = [] 66 | for header in self.headers: 67 | b_lines.append(header.encode("utf-8")) 68 | for entry in self.entries: 69 | for row in entry: 70 | b_lines.append(unicode(row).encode("utf-8")) 71 | with open(encode_filename(self.filename), "wt") as f: 72 | f.writelines(b_lines) 73 | 74 | 75 | class GeneratePlaylist(BaseAction): 76 | NAME = "Generate &Playlist..." 77 | 78 | def callback(self, objs): 79 | current_directory = (self.config.persist["current_directory"] 80 | or QtCore.QDir.homePath()) 81 | current_directory = find_existing_path(unicode(current_directory)) 82 | # Default playlist filename set as "%albumartist% - %album%.m3u8", 83 | # except where "Various Artists" is suppressed 84 | if _debug_level > 1: 85 | log.debug("{}: VARIOUS_ARTISTS_ID is {}, musicbrainz_albumartistid is {}".format( 86 | PLUGIN_NAME, VARIOUS_ARTISTS_ID, 87 | objs[0].metadata["musicbrainz_albumartistid"])) 88 | if objs[0].metadata["musicbrainz_albumartistid"] != VARIOUS_ARTISTS_ID: 89 | default_filename = get_safe_filename( 90 | objs[0].metadata["albumartist"] + " - " 91 | + objs[0].metadata["album"] + ".m3u8" 92 | ) 93 | else: 94 | default_filename = get_safe_filename( 95 | objs[0].metadata["album"] + ".m3u8" 96 | ) 97 | if _debug_level > 1: 98 | log.debug("{}: default playlist filename sanitized to {}".format( 99 | PLUGIN_NAME, default_filename)) 100 | b_filename, b_selected_format = QtGui.QFileDialog.getSaveFileNameAndFilter( 101 | None, "Save new playlist", 102 | os.path.join(current_directory, default_filename), 103 | "Playlist (*.m3u8 *.m3u)" 104 | ) 105 | if b_filename: 106 | filename = unicode(b_filename) 107 | playlist = Playlist(filename) 108 | playlist.add_header(u"#EXTM3U") 109 | 110 | for album in objs: 111 | for track in album.tracks: 112 | if track.linked_files: 113 | entry = PlaylistEntry(playlist, len(playlist.entries)) 114 | playlist.entries.append(entry) 115 | 116 | # M3U EXTINF row 117 | track_length_seconds = int(round(track.metadata.length / 1000.0)) 118 | # EXTINF format assumed to be fixed as follows: 119 | entry.add(u"#EXTINF:{duration:d},{artist} - {title}".format( 120 | duration=track_length_seconds, 121 | artist=track.metadata["artist"], 122 | title=track.metadata["title"] 123 | ) 124 | ) 125 | 126 | # M3U URL row - assumes only one file per track 127 | audio_filename = track.linked_files[0].filename 128 | if _debug_level > 1: 129 | for i, file in enumerate(track.linked_files): 130 | log.debug("{}: linked_file {}: {}".format( 131 | PLUGIN_NAME, i, str(file))) 132 | # If playlist is in same directory as audio files, then use 133 | # local (relative) pathname, otherwise use absolute pathname 134 | if _debug_level > 1: 135 | log.debug("{}: audio_filename: {}, selected dir: {}".format( 136 | PLUGIN_NAME, audio_filename, os.path.dirname(filename))) 137 | if os.path.dirname(filename) == os.path.dirname(audio_filename): 138 | audio_filename = os.path.basename(audio_filename) 139 | entry.add(unicode(audio_filename)) 140 | 141 | playlist.write() 142 | 143 | 144 | register_album_action(GeneratePlaylist()) 145 | -------------------------------------------------------------------------------- /plugins/release_type/release_type.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'Release Type' 2 | PLUGIN_AUTHOR = 'Elliot Chance' 3 | PLUGIN_DESCRIPTION = 'Appends information to EPs and Singles' 4 | PLUGIN_VERSION = '1.3' 5 | PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15"] 6 | 7 | from picard.metadata import register_album_metadata_processor 8 | 9 | RELEASE_TYPE_MAPPING = { 10 | "ep": " EP", 11 | "single": " (single)" 12 | } 13 | 14 | 15 | def add_release_type(tagger, metadata, release): 16 | 17 | # make sure "EP" (or "single", ...) is not already a word in the name 18 | words = metadata["album"].lower().split(" ") 19 | for word in ["ep", "e.p.", "single", "(single)"]: 20 | if word in words: 21 | return 22 | 23 | # check release type 24 | for releasetype, text in RELEASE_TYPE_MAPPING.iteritems(): 25 | if (metadata["~primaryreleasetype"] == releasetype) or ( 26 | metadata["releasetype"].startswith(releasetype)): 27 | rs = text 28 | break 29 | else: 30 | rs = "" 31 | 32 | # append title 33 | metadata["album"] = metadata["album"] + rs 34 | 35 | register_album_metadata_processor(add_release_type) 36 | -------------------------------------------------------------------------------- /plugins/remove_perfect_albums/remove_perfect_albums.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = u'Remove Perfect Albums' 2 | PLUGIN_AUTHOR = u'ichneumon, hrglgrmpf' 3 | PLUGIN_DESCRIPTION = u'''Remove all perfectly matched albums from the selection.''' 4 | PLUGIN_VERSION = '0.2' 5 | PLUGIN_API_VERSIONS = ['0.15'] 6 | PLUGIN_LICENSE = "GPL-2.0" 7 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 8 | 9 | from picard.album import Album 10 | from picard.ui.itemviews import BaseAction, register_album_action 11 | 12 | class RemovePerfectAlbums(BaseAction): 13 | NAME = 'Remove perfect albums' 14 | 15 | def callback(self, objs): 16 | for album in objs: 17 | if isinstance(album, Album) and album.is_complete() and album.get_num_unmatched_files() == 0\ 18 | and album.get_num_matched_tracks() == len(list(album.iterfiles()))\ 19 | and album.get_num_unsaved_files() == 0 and album.loaded == True: 20 | self.tagger.remove_album(album) 21 | 22 | register_album_action(RemovePerfectAlbums()) 23 | -------------------------------------------------------------------------------- /plugins/replaygain/options_replaygain.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | ReplayGainOptionsPage 4 | 5 | 6 | 7 | 0 8 | 0 9 | 305 10 | 317 11 | 12 | 13 | 14 | 15 | 6 16 | 17 | 18 | 9 19 | 20 | 21 | 22 | 23 | Replay Gain 24 | 25 | 26 | 27 | 2 28 | 29 | 30 | 9 31 | 32 | 33 | 34 | 35 | Path to VorbisGain: 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | Path to MP3Gain: 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Path to metaflac: 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | Path to wvgain: 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | Qt::Vertical 79 | 80 | 81 | 82 | 263 83 | 21 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /plugins/replaygain/ui_options_replaygain.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Automatically generated - don't edit. 4 | # Use `python setup.py build_ui` to update it. 5 | 6 | from PyQt4 import QtCore, QtGui 7 | 8 | try: 9 | _fromUtf8 = QtCore.QString.fromUtf8 10 | except AttributeError: 11 | _fromUtf8 = lambda s: s 12 | 13 | class Ui_ReplayGainOptionsPage(object): 14 | def setupUi(self, ReplayGainOptionsPage): 15 | ReplayGainOptionsPage.setObjectName(_fromUtf8("ReplayGainOptionsPage")) 16 | ReplayGainOptionsPage.resize(305, 317) 17 | self.vboxlayout = QtGui.QVBoxLayout(ReplayGainOptionsPage) 18 | self.vboxlayout.setSpacing(6) 19 | self.vboxlayout.setMargin(9) 20 | self.vboxlayout.setObjectName(_fromUtf8("vboxlayout")) 21 | self.replay_gain = QtGui.QGroupBox(ReplayGainOptionsPage) 22 | self.replay_gain.setObjectName(_fromUtf8("replay_gain")) 23 | self.vboxlayout1 = QtGui.QVBoxLayout(self.replay_gain) 24 | self.vboxlayout1.setSpacing(2) 25 | self.vboxlayout1.setMargin(9) 26 | self.vboxlayout1.setObjectName(_fromUtf8("vboxlayout1")) 27 | self.label = QtGui.QLabel(self.replay_gain) 28 | self.label.setObjectName(_fromUtf8("label")) 29 | self.vboxlayout1.addWidget(self.label) 30 | self.vorbisgain_command = QtGui.QLineEdit(self.replay_gain) 31 | self.vorbisgain_command.setObjectName(_fromUtf8("vorbisgain_command")) 32 | self.vboxlayout1.addWidget(self.vorbisgain_command) 33 | self.label_2 = QtGui.QLabel(self.replay_gain) 34 | self.label_2.setObjectName(_fromUtf8("label_2")) 35 | self.vboxlayout1.addWidget(self.label_2) 36 | self.mp3gain_command = QtGui.QLineEdit(self.replay_gain) 37 | self.mp3gain_command.setObjectName(_fromUtf8("mp3gain_command")) 38 | self.vboxlayout1.addWidget(self.mp3gain_command) 39 | self.label_3 = QtGui.QLabel(self.replay_gain) 40 | self.label_3.setObjectName(_fromUtf8("label_3")) 41 | self.vboxlayout1.addWidget(self.label_3) 42 | self.metaflac_command = QtGui.QLineEdit(self.replay_gain) 43 | self.metaflac_command.setObjectName(_fromUtf8("metaflac_command")) 44 | self.vboxlayout1.addWidget(self.metaflac_command) 45 | self.label_4 = QtGui.QLabel(self.replay_gain) 46 | self.label_4.setObjectName(_fromUtf8("label_4")) 47 | self.vboxlayout1.addWidget(self.label_4) 48 | self.wvgain_command = QtGui.QLineEdit(self.replay_gain) 49 | self.wvgain_command.setObjectName(_fromUtf8("wvgain_command")) 50 | self.vboxlayout1.addWidget(self.wvgain_command) 51 | self.vboxlayout.addWidget(self.replay_gain) 52 | spacerItem = QtGui.QSpacerItem(263, 21, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) 53 | self.vboxlayout.addItem(spacerItem) 54 | 55 | self.retranslateUi(ReplayGainOptionsPage) 56 | QtCore.QMetaObject.connectSlotsByName(ReplayGainOptionsPage) 57 | 58 | def retranslateUi(self, ReplayGainOptionsPage): 59 | self.replay_gain.setTitle(_("Replay Gain")) 60 | self.label.setText(_("Path to VorbisGain:")) 61 | self.label_2.setText(_("Path to MP3Gain:")) 62 | self.label_3.setText(_("Path to metaflac:")) 63 | self.label_4.setText(_("Path to wvgain:")) 64 | 65 | -------------------------------------------------------------------------------- /plugins/save_and_rewrite_header/save_and_rewrite_header.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This source code is a plugin for MusicBrainz Picard. 5 | # It adds a context menu action to save files and rewrite their header. 6 | # Copyright (C) 2015 Nicolas Cenerario 7 | # 8 | # This program is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program. If not, see . 20 | 21 | from __future__ import unicode_literals 22 | 23 | PLUGIN_NAME = "Save and rewrite header" 24 | PLUGIN_AUTHOR = "Nicolas Cenerario" 25 | PLUGIN_DESCRIPTION = "This plugin adds a context menu action to save files and rewrite their header." 26 | PLUGIN_VERSION = "0.2" 27 | PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15"] 28 | PLUGIN_LICENSE = "GPL-3.0" 29 | PLUGIN_LICENSE_URL = "http://www.gnu.org/licenses/gpl-3.0.txt" 30 | 31 | from _functools import partial 32 | from mutagen import File as MFile 33 | from picard.ui.itemviews import (BaseAction, register_album_action, 34 | register_cluster_action, 35 | register_clusterlist_action, 36 | register_track_action, register_file_action) 37 | from picard.metadata import Metadata 38 | from picard.util import thread 39 | 40 | 41 | class save_and_rewrite_header(BaseAction): 42 | 43 | NAME = "Save and rewrite header" 44 | 45 | def __init__(self): 46 | super(save_and_rewrite_header, self).__init__() 47 | register_file_action(self) 48 | register_track_action(self) 49 | register_album_action(self) 50 | register_cluster_action(self) 51 | register_clusterlist_action(self) 52 | 53 | def callback(self, obj): 54 | def save(pf): 55 | metadata = Metadata() 56 | metadata.copy(pf.metadata) 57 | mf = MFile(pf.filename) 58 | if mf is not None: 59 | mf.delete() 60 | return pf._save_and_rename(pf.filename, metadata) 61 | for f in self.tagger.get_files_from_objects(obj, save=True): 62 | f.set_pending() 63 | thread.run_task(partial(save, f), f._saving_finished, 64 | priority=2, thread_pool=f.tagger.save_thread_pool) 65 | 66 | save_and_rewrite_header() 67 | -------------------------------------------------------------------------------- /plugins/smart_title_case/smart_title_case.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8 -*- 3 | 4 | # This is the Smart Title Case plugin for MusicBrainz Picard. 5 | # Copyright (C) 2017 Sophist. 6 | # 7 | # It is based on the Title Case plugin by Javier Kohen 8 | # Copyright 2007 Javier Kohen 9 | # 10 | # This program is free software; you can redistribute it and/or 11 | # modify it under the terms of the GNU General Public License 12 | # as published by the Free Software Foundation; either version 2 13 | # of the License, or (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | 20 | PLUGIN_NAME = "Smart Title Case" 21 | PLUGIN_AUTHOR = u"Sophist based on an earlier plugin by Javier Kohen" 22 | PLUGIN_DESCRIPTION = """ 23 | Capitalize First Character In Every Word Of Album/Track Title/Artist.
24 | Leaves words containing embedded uppercase as-is i.e. USA or DoA.
25 | For Artist/AlbumArtist, title cases only artists not join phrases
26 | e.g. The Beatles feat. The Who. 27 | """ 28 | PLUGIN_VERSION = "0.1" 29 | PLUGIN_API_VERSIONS = ["1.4.0"] 30 | PLUGIN_LICENSE = "GPL-3.0" 31 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-3.0.html" 32 | 33 | import re, unicodedata 34 | 35 | title_tags = ['title', 'album'] 36 | artist_tags = [ 37 | ('artist', 'artists'), 38 | ('artistsort', '~artists_sort'), 39 | ('albumartist', '~albumartists'), 40 | ('albumartistsort', '~albumartists_sort'), 41 | ] 42 | title_re = re.compile(ur'\w[^-,/\s\u2010\u2011]*', re.UNICODE) 43 | 44 | def match_word(match): 45 | word = match.group(0) 46 | if word == word.lower(): 47 | word = word[0].upper() + word[1:] 48 | return word 49 | 50 | def string_title_match(match_word, string): 51 | return title_re.sub(match_word, string) 52 | 53 | def string_cleanup(string, locale="utf-8"): 54 | if not string: 55 | return u"" 56 | if not isinstance(string, unicode): 57 | string = string.decode(locale) 58 | # Replace with normalised unicode string 59 | return unicodedata.normalize("NFKC", string) 60 | 61 | def string_title_case(string, locale="utf-8"): 62 | """Title-case a string using a less destructive method than str.title.""" 63 | return string_title_match(match_word, string_cleanup(string, locale)) 64 | 65 | assert "Make Title Case" == string_title_case("make title case") 66 | assert "Already Title Case" == string_title_case("Already Title Case") 67 | assert "mIxEd cAsE" == string_title_case("mIxEd cAsE") 68 | assert "A" == string_title_case("a") 69 | assert "Apostrophe's Apostrophe's" == string_title_case("apostrophe's apostrophe's") 70 | assert "(Bracketed Text)" == string_title_case("(bracketed text)") 71 | assert "'Single Quotes'" == string_title_case("'single quotes'") 72 | assert '"Double Quotes"' == string_title_case('"double quotes"') 73 | assert "A,B" == string_title_case("a,b") 74 | assert "A-B" == string_title_case("a-b") 75 | assert "A/B" == string_title_case("a/b") 76 | 77 | def artist_title_case(text, artists, artists_upper): 78 | """ 79 | Use the array of artists and the joined string 80 | to identify artists to make title case 81 | and the join strings to leave as-is. 82 | """ 83 | find = u"^(" + ur")(\s+\S+?\s+)(".join((map(re.escape, map(string_cleanup,artists)))) + u")(.*$)" 84 | replace = "".join([ur"%s\%d" % (a, x*2 + 2) for x, a in enumerate(artists_upper)]) 85 | result = re.sub(find, replace, string_cleanup(text), re.UNICODE) 86 | return result 87 | 88 | assert "The Beatles feat. The Who" == artist_title_case( 89 | "the beatles feat. the who", 90 | ["the beatles", "the who"], 91 | ["The Beatles", "The Who"] 92 | ) 93 | 94 | # Put this here so that above unit tests can run standalone before getting an import error 95 | from picard import log 96 | from picard.metadata import ( 97 | register_track_metadata_processor, 98 | register_album_metadata_processor, 99 | ) 100 | 101 | def title_case(tagger, metadata, release, track=None): 102 | for name in title_tags: 103 | if name in metadata: 104 | values = metadata.getall(name) 105 | new_values = [string_title_case(v) for v in values] 106 | if values != new_values: 107 | log.debug("SmartTitleCase: %s: %r replaced with %r", name, values, new_values) 108 | metadata[name] = new_values 109 | for artist_string, artists_list in artist_tags: 110 | if artist_string in metadata and artists_list in metadata: 111 | artist = metadata.getall(artist_string) 112 | artists = metadata.getall(artists_list) 113 | new_artists = map(string_title_case, artists) 114 | new_artist = [artist_title_case(x, artists, new_artists) for x in artist] 115 | if artists != new_artists and artist != new_artist: 116 | log.debug("SmartTitleCase: %s: %s replaced with %s", artist_string, artist, new_artist) 117 | log.debug("SmartTitleCase: %s: %r replaced with %r", artists_list, artists, new_artists) 118 | metadata[artist_string] = new_artist 119 | metadata[artists_list] = new_artists 120 | elif artists != new_artists or artist != new_artist: 121 | if artists != new_artists: 122 | log.warning("SmartTitleCase: %s changed, %s wasn't", artists_list, artist_string) 123 | log.warning("SmartTitleCase: %s: %r changed to %r", artists_list, artists, new_artists) 124 | log.warning("SmartTitleCase: %s: %r unchanged", artist_string, artist) 125 | else: 126 | log.warning("SmartTitleCase: %s changed, %s wasn't", artist_string, artists_list) 127 | log.warning("SmartTitleCase: %s: %r changed to %r", artist_string, artist, new_artist) 128 | log.warning("SmartTitleCase: %s: %r unchanged", artists_list, artists) 129 | 130 | register_track_metadata_processor(title_case) 131 | register_album_metadata_processor(title_case) 132 | -------------------------------------------------------------------------------- /plugins/sort_multivalue_tags/sort_multivalue_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This is the Sort Multivalue Tags plugin for MusicBrainz Picard. 4 | # Copyright (C) 2013 Sophist 5 | # 6 | # This program is free software; you can redistribute it and/or 7 | # modify it under the terms of the GNU General Public License 8 | # as published by the Free Software Foundation; either version 2 9 | # of the License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | 16 | PLUGIN_NAME = u"Sort Multi-Value Tags" 17 | PLUGIN_AUTHOR = u"Sophist" 18 | PLUGIN_DESCRIPTION = u''' 19 | This plugin sorts multi-value tags e.g. Performers alphabetically.

20 | Note: Some multi-value tags are excluded for the following reasons: 21 |
    22 |
  1. Sequence is important e.g. Artists
  2. 23 |
  3. The sequence of one tag is linked to the sequence of another e.g. Label and Catalogue number.
  4. 24 |
25 | ''' 26 | PLUGIN_VERSION = "0.2" 27 | PLUGIN_API_VERSIONS = ["0.15"] 28 | PLUGIN_LICENSE = "GPL-2.0" 29 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 30 | 31 | from picard.metadata import register_track_metadata_processor 32 | 33 | # Define tags where sort order is important 34 | _sort_multivalue_tags_exclude = ( 35 | 'artists', '~artists_sort', 'musicbrainz_artistid', 36 | 'albumartists', '~albumartists_sort', 'musicbrainz_albumartistid', 37 | 'work', 'musicbrainz_workid', 38 | 'label', 'catalognumber', 39 | 'country', 'date', 40 | 'releasetype', 41 | ) 42 | # Possible future enhancement: 43 | # Sort linked tags e.g. work so that the sequence in related tags e.g. workid retains the relationship between 44 | # e.g. work and workid. 45 | 46 | 47 | def sort_multivalue_tags(tagger, metadata, track, release): 48 | 49 | for tag in metadata.keys(): 50 | if tag in _sort_multivalue_tags_exclude: 51 | continue 52 | data = metadata.getall(tag) 53 | if len(data) > 1: 54 | sorted_data = sorted(data) 55 | if data != sorted_data: 56 | metadata.set(tag, sorted_data) 57 | 58 | register_track_metadata_processor(sort_multivalue_tags) 59 | -------------------------------------------------------------------------------- /plugins/soundtrack/soundtrack.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright © 2015 Samir Benmendil 4 | # This work is free. You can redistribute it and/or modify it under the 5 | # terms of the Do What The Fuck You Want To Public License, Version 2, 6 | # as published by Sam Hocevar. See http://www.wtfpl.net/ for more details. 7 | 8 | PLUGIN_NAME = 'Soundtrack' 9 | PLUGIN_AUTHOR = 'Samir Benmendil' 10 | PLUGIN_LICENSE = 'WTFPL' 11 | PLUGIN_LICENSE_URL = 'http://www.wtfpl.net/' 12 | PLUGIN_DESCRIPTION = '''Sets the albumartist to "Soundtrack" if releasetype is a soundtrack.''' 13 | PLUGIN_VERSION = "0.1" 14 | PLUGIN_API_VERSIONS = ["1.0"] 15 | 16 | from picard.metadata import register_album_metadata_processor 17 | 18 | 19 | def soundtrack(tagger, metadata, release): 20 | if "soundtrack" in metadata["releasetype"]: 21 | metadata["albumartist"] = "Soundtrack" 22 | metadata["albumartistsort"] = "Soundtrack" 23 | 24 | register_album_metadata_processor(soundtrack) 25 | -------------------------------------------------------------------------------- /plugins/standardise_feat/standardise_feat.py: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = 'Standardise Feat.' 2 | PLUGIN_AUTHOR = 'Sambhav Kothari' 3 | PLUGIN_DESCRIPTION = 'Standardises "featuring" join phrases for artists to "feat."' 4 | PLUGIN_VERSION = "0.1" 5 | PLUGIN_API_VERSIONS = ["1.4"] 6 | 7 | import re 8 | from picard import log 9 | from picard.metadata import register_track_metadata_processor, register_album_metadata_processor 10 | from picard.plugin import PluginPriority 11 | 12 | _feat_re = re.compile(r" f(ea)?t(\.|uring)? ", re.IGNORECASE) 13 | 14 | 15 | def standardise_feat(artists_str, artists_list): 16 | match_exp = r"(\s*.*\s*)".join((map(re.escape, artists_list))) 17 | try: 18 | join_phrases = re.match(match_exp, artists_str).groups() 19 | except AttributeError: 20 | log.debug("Unable to standardise artists: %r", artists_str) 21 | return artists_str 22 | else: 23 | standardised_join_phrases = [_feat_re.sub(" feat. ", phrase) 24 | for phrase in join_phrases] 25 | # Add a blank string at the end to allow zipping of 26 | # join phrases and artists_list since there is one less join phrase 27 | standardised_join_phrases.append("") 28 | return "".join([artist + join_phrase for artist, join_phrase in 29 | zip(artists_list, standardised_join_phrases)]) 30 | 31 | 32 | def standardise_track_artist(tagger, metadata, release, track): 33 | metadata["artist"] = standardise_feat(metadata["artist"], metadata.getall("artists")) 34 | metadata["artistsort"] = standardise_feat(metadata["artistsort"], metadata.getall("~artists_sort")) 35 | 36 | 37 | def standardise_album_artist(tagger, metadata, release): 38 | metadata["albumartist"] = standardise_feat(metadata["albumartist"], metadata.getall("~albumartists")) 39 | metadata["albumartistsort"] = standardise_feat(metadata["albumartistsort"], metadata.getall("~albumartists_sort")) 40 | 41 | 42 | register_track_metadata_processor(standardise_track_artist, priority=PluginPriority.HIGH) 43 | register_album_metadata_processor(standardise_album_artist, priority=PluginPriority.HIGH) 44 | -------------------------------------------------------------------------------- /plugins/standardise_performers/standardise_performers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | PLUGIN_NAME = u'Standardise Performers' 4 | PLUGIN_AUTHOR = u'Sophist' 5 | PLUGIN_DESCRIPTION = u'''Splits multi-instrument performer tags into single 6 | instruments and combines names so e.g. (from 10cc by 10cc track 1): 7 |
 8 | Performer [acoustic guitar, bass, dobro, electric guitar and tambourine]: Graham Gouldman
 9 | Performer [acoustic guitar, electric guitar, grand piano and synthesizer]: Lol Creme
10 | Performer [electric guitar, moog and slide guitar]: Eric Stewart
11 | 
12 | becomes: 13 |
14 | Performer [acoustic guitar]: Graham Gouldman; Lol Creme
15 | Performer [bass]: Graham Gouldman
16 | Performer [dobro]: Graham Gouldman
17 | Performer [electric guitar]: Eric Stewart; Graham Gouldman; Lol Creme
18 | Performer [grand piano]: Lol Creme
19 | Performer [moog]: Eric Stewart
20 | Performer [slide guitar]: Eric Stewart
21 | Performer [synthesizer]: Lol Creme
22 | Performer [tambourine]: Graham Gouldman
23 | 
24 | ''' 25 | PLUGIN_VERSION = '0.2' 26 | PLUGIN_API_VERSIONS = ["0.15.0", "0.15.1", "0.16.0", "1.0.0", "1.1.0", "1.2.0", "1.3.0"] 27 | PLUGIN_LICENSE = "GPL-2.0" 28 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 29 | 30 | import re 31 | from picard import log 32 | from picard.metadata import register_track_metadata_processor 33 | 34 | standardise_performers_split = re.compile(r", | and ").split 35 | 36 | 37 | def standardise_performers(album, metadata, *args): 38 | for key, values in metadata.rawitems(): 39 | if not key.startswith('performer:') \ 40 | and not key.startswith('~performersort:'): 41 | continue 42 | mainkey, subkey = key.split(':', 1) 43 | if not subkey: 44 | continue 45 | instruments = standardise_performers_split(subkey) 46 | if len(instruments) == 1: 47 | continue 48 | log.debug("%s: Splitting Performer [%s] into separate performers", 49 | PLUGIN_NAME, 50 | subkey, 51 | ) 52 | for instrument in instruments: 53 | newkey = '%s:%s' % (mainkey, instrument) 54 | for value in values: 55 | metadata.add_unique(newkey, value) 56 | del metadata[key] 57 | 58 | 59 | try: 60 | from picard.plugin import PluginPriority 61 | 62 | register_track_metadata_processor(standardise_performers, 63 | priority=PluginPriority.HIGH) 64 | except ImportError: 65 | log.warning( 66 | "Running %r plugin on this Picard version may not work as you expect. " 67 | "Any other plugins that run before it will get the old performers " 68 | "rather than the standardized performers.", PLUGIN_NAME 69 | ) 70 | register_track_metadata_processor(standardise_performers) 71 | -------------------------------------------------------------------------------- /plugins/titlecase/titlecase.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2007 Javier Kohen 3 | # 4 | # This program is free software; you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License version 2 as 6 | # published by the Free Software Foundation 7 | 8 | PLUGIN_NAME = "Title Case" 9 | PLUGIN_AUTHOR = u"Javier Kohen" 10 | PLUGIN_DESCRIPTION = "Capitalize First Character In Every Word Of A Title" 11 | PLUGIN_VERSION = "0.1" 12 | PLUGIN_API_VERSIONS = ["0.9", "0.10", "0.11", "0.15"] 13 | PLUGIN_LICENSE = "GPL-2.0" 14 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 15 | 16 | import unicodedata 17 | 18 | 19 | def iswbound(char): 20 | """Returns whether the given character is a word boundary.""" 21 | category = unicodedata.category(char) 22 | # If it's a space separator or punctuation 23 | return 'Zs' == category or 'Sk' == category or 'P' == category[0] 24 | 25 | 26 | def utitle(string): 27 | """Title-case a string using a less destructive method than str.title.""" 28 | new_string = string[0].capitalize() 29 | cap = False 30 | for i in xrange(1, len(string)): 31 | s = string[i] 32 | # Special case apostrophe in the middle of a word. 33 | if s in u"’'" and string[i - 1].isalpha(): 34 | cap = False 35 | elif iswbound(s): 36 | cap = True 37 | elif cap and s.isalpha(): 38 | cap = False 39 | s = s.capitalize() 40 | else: 41 | cap = False 42 | new_string += s 43 | return new_string 44 | 45 | 46 | def title(string, locale="utf-8"): 47 | """Title-case a string using a less destructive method than str.title.""" 48 | if not string: 49 | return u"" 50 | # if the string is all uppercase, lowercase it - Erich/Javier 51 | # Lots of Japanese songs use entirely upper-case English titles, 52 | # so I don't like this change... - JoeW 53 | #if string == string.upper(): string = string.lower() 54 | if not isinstance(string, unicode): 55 | string = string.decode(locale) 56 | return utitle(string) 57 | 58 | from picard.metadata import ( 59 | register_track_metadata_processor, 60 | register_album_metadata_processor, 61 | ) 62 | 63 | 64 | def title_case(tagger, metadata, release, track=None): 65 | for name, value in metadata.rawitems(): 66 | if name in ["title", "album", "artist"]: 67 | metadata[name] = [title(x) for x in value] 68 | 69 | register_track_metadata_processor(title_case) 70 | register_album_metadata_processor(title_case) 71 | -------------------------------------------------------------------------------- /plugins/tracks2clipboard/tracks2clipboard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | PLUGIN_NAME = u"Copy Cluster to Clipboard" 4 | PLUGIN_AUTHOR = u"Michael Elsdörfer" 5 | PLUGIN_DESCRIPTION = "Exports a cluster's tracks to the clipboard, so it can be copied into the tracklist field on MusicBrainz" 6 | PLUGIN_VERSION = "0.1" 7 | PLUGIN_API_VERSIONS = ["0.9.0", "0.10", "0.15"] 8 | 9 | 10 | from PyQt4 import QtCore, QtGui 11 | from picard.cluster import Cluster 12 | from picard.util import format_time 13 | from picard.ui.itemviews import BaseAction, register_cluster_action 14 | 15 | 16 | class CopyClusterToClipboard(BaseAction): 17 | NAME = "Copy Cluster to Clipboard..." 18 | 19 | def callback(self, objs): 20 | if len(objs) != 1 or not isinstance(objs[0], Cluster): 21 | return 22 | cluster = objs[0] 23 | 24 | artists = set() 25 | for i, file in enumerate(cluster.files): 26 | artists.add(file.metadata["artist"]) 27 | 28 | tracks = [] 29 | for i, file in enumerate(cluster.files): 30 | try: 31 | i = int(file.metadata["tracknumber"]) 32 | except: 33 | i += 1 34 | 35 | if len(artists) > 1: 36 | tracks.append((i, "%s. %s - %s (%s)" % ( 37 | i, 38 | file.metadata["title"], 39 | file.metadata["artist"], 40 | format_time(file.metadata.length)))) 41 | else: 42 | tracks.append((i, "%s. %s (%s)" % ( 43 | i, 44 | file.metadata["title"], 45 | format_time(file.metadata.length)))) 46 | 47 | clipboard = QtGui.QApplication.clipboard() 48 | clipboard.setText("\n".join(map(lambda x: x[1], sorted(tracks)))) 49 | 50 | 51 | register_cluster_action(CopyClusterToClipboard()) 52 | -------------------------------------------------------------------------------- /plugins/videotools/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014 Philipp Wolfer 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License 7 | # as published by the Free Software Foundation; either version 2 8 | # of the License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software 17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 18 | # 02110-1301, USA. 19 | 20 | PLUGIN_NAME = 'Video tools' 21 | PLUGIN_AUTHOR = 'Philipp Wolfer' 22 | 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.' 23 | PLUGIN_VERSION = "0.1" 24 | PLUGIN_API_VERSIONS = ["1.3.0"] 25 | PLUGIN_LICENSE = "GPL-2.0" 26 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 27 | 28 | from picard.formats import register_format 29 | from picard.script import register_script_function 30 | from picard.plugins.videotools.formats import MatroskaFile, MpegFile, QuickTimeFile, RiffFile 31 | from picard.plugins.videotools.script import is_audio, is_video 32 | 33 | 34 | # Now this is kind of a hack, but Picard won't process registered objects that 35 | # are in a submodule of a plugin. I still want the code to be in separate files. 36 | MatroskaFile.__module__ = MpegFile.__module__ = QuickTimeFile.__module__ = \ 37 | RiffFile.__module__ = is_audio.__module__ = is_video.__module__ = \ 38 | __name__ 39 | 40 | register_format(MatroskaFile) 41 | register_format(MpegFile) 42 | register_format(QuickTimeFile) 43 | register_format(RiffFile) 44 | 45 | register_script_function(is_audio) 46 | register_script_function(is_video) 47 | -------------------------------------------------------------------------------- /plugins/videotools/enzyme/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # enzyme - Video metadata parser 3 | # Copyright 2011-2012 Antoine Bertin 4 | # Copyright 2003-2006 Thomas Schueppel 5 | # Copyright 2003-2006 Dirk Meyer 6 | # 7 | # This file is part of enzyme. 8 | # 9 | # enzyme is free software; you can redistribute it and/or modify it under 10 | # the terms of the GNU General Public License as published by 11 | # the Free Software Foundation; either version 3 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # enzyme is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with enzyme. If not, see . 21 | from __future__ import absolute_import 22 | import mimetypes 23 | import os 24 | import sys 25 | from .exceptions import * 26 | 27 | 28 | PARSERS = [('asf', ['video/asf'], ['asf', 'wmv', 'wma']), 29 | ('flv', ['video/flv'], ['flv']), 30 | ('mkv', ['video/x-matroska', 'application/mkv'], ['mkv', 'mka', 'webm']), 31 | ('mp4', ['video/quicktime', 'video/mp4'], ['mov', 'qt', 'mp4', 'mp4a', '3gp', '3gp2', '3g2', 'mk2']), 32 | ('mpeg', ['video/mpeg'], ['mpeg', 'mpg', 'mp4', 'ts']), 33 | ('ogm', ['application/ogg'], ['ogm', 'ogg', 'ogv']), 34 | ('real', ['video/real'], ['rm', 'ra', 'ram']), 35 | ('riff', ['video/avi'], ['wav', 'avi']) 36 | ] 37 | 38 | 39 | def parse(path): 40 | """Parse metadata of the given video 41 | 42 | :param string path: path to the video file to parse 43 | :return: a parser corresponding to the video's mimetype or extension 44 | :rtype: :class:`~enzyme.core.AVContainer` 45 | 46 | """ 47 | if not os.path.isfile(path): 48 | raise ValueError('Invalid path') 49 | extension = os.path.splitext(path)[1][1:] 50 | mimetype = mimetypes.guess_type(path)[0] 51 | parser_ext = None 52 | parser_mime = None 53 | for (parser_name, parser_mimetypes, parser_extensions) in PARSERS: 54 | if mimetype in parser_mimetypes: 55 | parser_mime = parser_name 56 | if extension in parser_extensions: 57 | parser_ext = parser_name 58 | parser = parser_mime or parser_ext 59 | if not parser: 60 | raise NoParserError() 61 | mod = __import__(parser, globals=globals(), locals=locals(), fromlist=[], level=-1) 62 | with open(path, 'rb') as f: 63 | p = mod.Parser(f) 64 | return p 65 | -------------------------------------------------------------------------------- /plugins/videotools/enzyme/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # enzyme - Video metadata parser 3 | # Copyright 2011-2012 Antoine Bertin 4 | # 5 | # This file is part of enzyme. 6 | # 7 | # enzyme is free software; you can redistribute it and/or modify it under 8 | # the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # enzyme is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with enzyme. If not, see . 19 | class Error(Exception): 20 | pass 21 | 22 | 23 | class NoParserError(Error): 24 | pass 25 | 26 | 27 | class ParseError(Error): 28 | pass 29 | -------------------------------------------------------------------------------- /plugins/videotools/enzyme/flv.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # enzyme - Video metadata parser 3 | # Copyright 2011-2012 Antoine Bertin 4 | # Copyright 2003-2006 Dirk Meyer 5 | # 6 | # This file is part of enzyme. 7 | # 8 | # enzyme is free software; you can redistribute it and/or modify it under 9 | # the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # enzyme is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with enzyme. If not, see . 20 | from __future__ import absolute_import 21 | from .exceptions import ParseError 22 | from . import core 23 | import logging 24 | import struct 25 | 26 | __all__ = ['Parser'] 27 | 28 | 29 | # get logging object 30 | log = logging.getLogger(__name__) 31 | 32 | FLV_TAG_TYPE_AUDIO = 0x08 33 | FLV_TAG_TYPE_VIDEO = 0x09 34 | FLV_TAG_TYPE_META = 0x12 35 | 36 | # audio flags 37 | FLV_AUDIO_CHANNEL_MASK = 0x01 38 | FLV_AUDIO_SAMPLERATE_MASK = 0x0c 39 | FLV_AUDIO_CODECID_MASK = 0xf0 40 | 41 | FLV_AUDIO_SAMPLERATE_OFFSET = 2 42 | FLV_AUDIO_CODECID_OFFSET = 4 43 | FLV_AUDIO_CODECID = (0x0001, 0x0002, 0x0055, 0x0001) 44 | 45 | # video flags 46 | FLV_VIDEO_CODECID_MASK = 0x0f 47 | FLV_VIDEO_CODECID = ('FLV1', 'MSS1', 'VP60') # wild guess 48 | 49 | FLV_DATA_TYPE_NUMBER = 0x00 50 | FLV_DATA_TYPE_BOOL = 0x01 51 | FLV_DATA_TYPE_STRING = 0x02 52 | FLV_DATA_TYPE_OBJECT = 0x03 53 | FLC_DATA_TYPE_CLIP = 0x04 54 | FLV_DATA_TYPE_REFERENCE = 0x07 55 | FLV_DATA_TYPE_ECMARRAY = 0x08 56 | FLV_DATA_TYPE_ENDOBJECT = 0x09 57 | FLV_DATA_TYPE_ARRAY = 0x0a 58 | FLV_DATA_TYPE_DATE = 0x0b 59 | FLV_DATA_TYPE_LONGSTRING = 0x0c 60 | 61 | FLVINFO = { 62 | 'creator': 'copyright', 63 | } 64 | 65 | class FlashVideo(core.AVContainer): 66 | """ 67 | Experimental parser for Flash videos. It requires certain flags to 68 | be set to report video resolutions and in most cases it does not 69 | provide that information. 70 | """ 71 | table_mapping = { 'FLVINFO' : FLVINFO } 72 | 73 | def __init__(self, file): 74 | core.AVContainer.__init__(self) 75 | self.mime = 'video/flv' 76 | self.type = 'Flash Video' 77 | data = file.read(13) 78 | if len(data) < 13 or struct.unpack('>3sBBII', data)[0] != 'FLV': 79 | raise ParseError() 80 | 81 | for _ in range(10): 82 | if self.audio and self.video: 83 | break 84 | data = file.read(11) 85 | if len(data) < 11: 86 | break 87 | chunk = struct.unpack('>BH4BI', data) 88 | size = (chunk[1] << 8) + chunk[2] 89 | 90 | if chunk[0] == FLV_TAG_TYPE_AUDIO: 91 | flags = ord(file.read(1)) 92 | if not self.audio: 93 | a = core.AudioStream() 94 | a.channels = (flags & FLV_AUDIO_CHANNEL_MASK) + 1 95 | srate = (flags & FLV_AUDIO_SAMPLERATE_MASK) 96 | a.samplerate = (44100 << (srate >> FLV_AUDIO_SAMPLERATE_OFFSET) >> 3) 97 | codec = (flags & FLV_AUDIO_CODECID_MASK) >> FLV_AUDIO_CODECID_OFFSET 98 | if codec < len(FLV_AUDIO_CODECID): 99 | a.codec = FLV_AUDIO_CODECID[codec] 100 | self.audio.append(a) 101 | 102 | file.seek(size - 1, 1) 103 | 104 | elif chunk[0] == FLV_TAG_TYPE_VIDEO: 105 | flags = ord(file.read(1)) 106 | if not self.video: 107 | v = core.VideoStream() 108 | codec = (flags & FLV_VIDEO_CODECID_MASK) - 2 109 | if codec < len(FLV_VIDEO_CODECID): 110 | v.codec = FLV_VIDEO_CODECID[codec] 111 | # width and height are in the meta packet, but I have 112 | # no file with such a packet inside. So maybe we have 113 | # to decode some parts of the video. 114 | self.video.append(v) 115 | 116 | file.seek(size - 1, 1) 117 | 118 | elif chunk[0] == FLV_TAG_TYPE_META: 119 | log.info(u'metadata %r', str(chunk)) 120 | metadata = file.read(size) 121 | try: 122 | while metadata: 123 | length, value = self._parse_value(metadata) 124 | if isinstance(value, dict): 125 | log.info(u'metadata: %r', value) 126 | if value.get('creator'): 127 | self.copyright = value.get('creator') 128 | if value.get('width'): 129 | self.width = value.get('width') 130 | if value.get('height'): 131 | self.height = value.get('height') 132 | if value.get('duration'): 133 | self.length = value.get('duration') 134 | self._appendtable('FLVINFO', value) 135 | if not length: 136 | # parse error 137 | break 138 | metadata = metadata[length:] 139 | except (IndexError, struct.error, TypeError): 140 | pass 141 | else: 142 | log.info(u'unkown %r', str(chunk)) 143 | file.seek(size, 1) 144 | 145 | file.seek(4, 1) 146 | 147 | def _parse_value(self, data): 148 | """ 149 | Parse the next metadata value. 150 | """ 151 | if ord(data[0]) == FLV_DATA_TYPE_NUMBER: 152 | value = struct.unpack('>d', data[1:9])[0] 153 | return 9, value 154 | 155 | if ord(data[0]) == FLV_DATA_TYPE_BOOL: 156 | return 2, bool(data[1]) 157 | 158 | if ord(data[0]) == FLV_DATA_TYPE_STRING: 159 | length = (ord(data[1]) << 8) + ord(data[2]) 160 | return length + 3, data[3:length + 3] 161 | 162 | if ord(data[0]) == FLV_DATA_TYPE_ECMARRAY: 163 | init_length = len(data) 164 | num = struct.unpack('>I', data[1:5])[0] 165 | data = data[5:] 166 | result = {} 167 | for _ in range(num): 168 | length = (ord(data[0]) << 8) + ord(data[1]) 169 | key = data[2:length + 2] 170 | data = data[length + 2:] 171 | length, value = self._parse_value(data) 172 | if not length: 173 | return 0, result 174 | result[key] = value 175 | data = data[length:] 176 | return init_length - len(data), result 177 | 178 | log.info(u'unknown code: %x. Stop metadata parser', ord(data[0])) 179 | return 0, None 180 | 181 | 182 | Parser = FlashVideo 183 | -------------------------------------------------------------------------------- /plugins/videotools/enzyme/infos.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # enzyme - Video metadata parser 3 | # Copyright 2011-2012 Antoine Bertin 4 | # 5 | # This file is part of enzyme. 6 | # 7 | # enzyme is free software; you can redistribute it and/or modify it under 8 | # the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # enzyme is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with enzyme. If not, see . 19 | __version__ = '0.2' 20 | -------------------------------------------------------------------------------- /plugins/videotools/enzyme/real.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # enzyme - Video metadata parser 3 | # Copyright 2011-2012 Antoine Bertin 4 | # Copyright 2003-2006 Thomas Schueppel 5 | # Copyright 2003-2006 Dirk Meyer 6 | # 7 | # This file is part of enzyme. 8 | # 9 | # enzyme is free software; you can redistribute it and/or modify it under 10 | # the terms of the GNU General Public License as published by 11 | # the Free Software Foundation; either version 3 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # enzyme is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with enzyme. If not, see . 21 | from __future__ import absolute_import 22 | __all__ = ['Parser'] 23 | 24 | import struct 25 | import logging 26 | from .exceptions import ParseError 27 | from . import core 28 | 29 | # http://www.pcisys.net/~melanson/codecs/rmff.htm 30 | # http://www.pcisys.net/~melanson/codecs/ 31 | 32 | # get logging object 33 | log = logging.getLogger(__name__) 34 | 35 | class RealVideo(core.AVContainer): 36 | def __init__(self, file): 37 | core.AVContainer.__init__(self) 38 | self.mime = 'video/real' 39 | self.type = 'Real Video' 40 | h = file.read(10) 41 | try: 42 | (object_id, object_size, object_version) = struct.unpack('>4sIH', h) 43 | except struct.error: 44 | # EOF. 45 | raise ParseError() 46 | 47 | if not object_id == '.RMF': 48 | raise ParseError() 49 | 50 | file_version, num_headers = struct.unpack('>II', file.read(8)) 51 | log.debug(u'size: %d, ver: %d, headers: %d' % \ 52 | (object_size, file_version, num_headers)) 53 | for _ in range(0, num_headers): 54 | try: 55 | oi = struct.unpack('>4sIH', file.read(10)) 56 | except (struct.error, IOError): 57 | # Header data we expected wasn't there. File may be 58 | # only partially complete. 59 | break 60 | 61 | if object_id == 'DATA' and oi[0] != 'INDX': 62 | log.debug(u'INDX chunk expected after DATA but not found -- file corrupt') 63 | break 64 | 65 | (object_id, object_size, object_version) = oi 66 | if object_id == 'DATA': 67 | # Seek over the data chunk rather than reading it in. 68 | file.seek(object_size - 10, 1) 69 | else: 70 | self._read_header(object_id, file.read(object_size - 10)) 71 | log.debug(u'%r [%d]' % (object_id, object_size - 10)) 72 | # Read all the following headers 73 | 74 | 75 | def _read_header(self, object_id, s): 76 | if object_id == 'PROP': 77 | prop = struct.unpack('>9IHH', s) 78 | log.debug(u'PROP: %r' % prop) 79 | if object_id == 'MDPR': 80 | mdpr = struct.unpack('>H7I', s[:30]) 81 | log.debug(u'MDPR: %r' % mdpr) 82 | self.length = mdpr[7] / 1000.0 83 | (stream_name_size,) = struct.unpack('>B', s[30:31]) 84 | stream_name = s[31:31 + stream_name_size] 85 | pos = 31 + stream_name_size 86 | (mime_type_size,) = struct.unpack('>B', s[pos:pos + 1]) 87 | mime = s[pos + 1:pos + 1 + mime_type_size] 88 | pos += mime_type_size + 1 89 | (type_specific_len,) = struct.unpack('>I', s[pos:pos + 4]) 90 | type_specific = s[pos + 4:pos + 4 + type_specific_len] 91 | pos += 4 + type_specific_len 92 | if mime[:5] == 'audio': 93 | ai = core.AudioStream() 94 | ai.id = mdpr[0] 95 | ai.bitrate = mdpr[2] 96 | self.audio.append(ai) 97 | elif mime[:5] == 'video': 98 | vi = core.VideoStream() 99 | vi.id = mdpr[0] 100 | vi.bitrate = mdpr[2] 101 | self.video.append(vi) 102 | else: 103 | log.debug(u'Unknown: %r' % mime) 104 | if object_id == 'CONT': 105 | pos = 0 106 | (title_len,) = struct.unpack('>H', s[pos:pos + 2]) 107 | self.title = s[2:title_len + 2] 108 | pos += title_len + 2 109 | (author_len,) = struct.unpack('>H', s[pos:pos + 2]) 110 | self.artist = s[pos + 2:pos + author_len + 2] 111 | pos += author_len + 2 112 | (copyright_len,) = struct.unpack('>H', s[pos:pos + 2]) 113 | self.copyright = s[pos + 2:pos + copyright_len + 2] 114 | pos += copyright_len + 2 115 | (comment_len,) = struct.unpack('>H', s[pos:pos + 2]) 116 | self.comment = s[pos + 2:pos + comment_len + 2] 117 | 118 | 119 | Parser = RealVideo 120 | -------------------------------------------------------------------------------- /plugins/videotools/enzyme/strutils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # enzyme - Video metadata parser 3 | # Copyright 2011-2012 Antoine Bertin 4 | # Copyright 2006-2009 Dirk Meyer 5 | # Copyright 2006-2009 Jason Tackaberry 6 | # 7 | # This file is part of enzyme. 8 | # 9 | # enzyme is free software; you can redistribute it and/or modify it under 10 | # the terms of the GNU General Public License as published by 11 | # the Free Software Foundation; either version 3 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # enzyme is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with enzyme. If not, see . 21 | __all__ = ['ENCODING', 'str_to_unicode', 'unicode_to_str'] 22 | 23 | import locale 24 | 25 | # find the correct encoding 26 | try: 27 | ENCODING = locale.getdefaultlocale()[1] 28 | ''.encode(ENCODING) 29 | except (UnicodeError, TypeError): 30 | ENCODING = 'latin-1' 31 | 32 | 33 | def str_to_unicode(s, encoding=None): 34 | """ 35 | Attempts to convert a string of unknown character set to a unicode 36 | string. First it tries to decode the string based on the locale's 37 | preferred encoding, and if that fails, fall back to UTF-8 and then 38 | latin-1. If all fails, it will force encoding to the preferred 39 | charset, replacing unknown characters. If the given object is no 40 | string, this function will return the given object. 41 | """ 42 | if not type(s) == str: 43 | return s 44 | 45 | if not encoding: 46 | encoding = ENCODING 47 | 48 | for c in [encoding, "utf-8", "latin-1"]: 49 | try: 50 | return s.decode(c) 51 | except UnicodeDecodeError: 52 | pass 53 | 54 | return s.decode(encoding, "replace") 55 | 56 | 57 | def unicode_to_str(s, encoding=None): 58 | """ 59 | Attempts to convert a unicode string of unknown character set to a 60 | string. First it tries to encode the string based on the locale's 61 | preferred encoding, and if that fails, fall back to UTF-8 and then 62 | latin-1. If all fails, it will force encoding to the preferred 63 | charset, replacing unknown characters. If the given object is no 64 | unicode string, this function will return the given object. 65 | """ 66 | if not type(s) == unicode: 67 | return s 68 | 69 | if not encoding: 70 | encoding = ENCODING 71 | 72 | for c in [encoding, "utf-8", "latin-1"]: 73 | try: 74 | return s.encode(c) 75 | except UnicodeDecodeError: 76 | pass 77 | 78 | return s.encode(encoding, "replace") 79 | -------------------------------------------------------------------------------- /plugins/videotools/formats.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014 Philipp Wolfer 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License 7 | # as published by the Free Software Foundation; either version 2 8 | # of the License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software 17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 18 | # 02110-1301, USA. 19 | 20 | from __future__ import absolute_import 21 | # -*- coding: utf-8 -*- 22 | # 23 | # Copyright (C) 2014 Philipp Wolfer 24 | # 25 | # This program is free software; you can redistribute it and/or 26 | # modify it under the terms of the GNU General Public License 27 | # as published by the Free Software Foundation; either version 2 28 | # of the License, or (at your option) any later version. 29 | # 30 | # This program is distributed in the hope that it will be useful, 31 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 32 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 33 | # GNU General Public License for more details. 34 | # 35 | # You should have received a copy of the GNU General Public License 36 | # along with this program; if not, write to the Free Software 37 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 38 | # 02110-1301, USA. 39 | 40 | from . import enzyme 41 | from picard import log 42 | from picard.file import File 43 | from picard.formats import register_format 44 | from picard.formats.wav import WAVFile 45 | from picard.metadata import Metadata 46 | 47 | 48 | class EnzymeFile(File): 49 | 50 | def _load(self, filename): 51 | log.debug("Loading file %r", filename) 52 | metadata = Metadata() 53 | self._add_path_to_metadata(metadata) 54 | 55 | try: 56 | parser = enzyme.parse(filename) 57 | log.debug("Metadata for %s:\n%s", filename, unicode(parser)) 58 | self._convertMetadata(parser, metadata) 59 | except Exception as err: 60 | log.error("Could not parse file %r: %r", filename, err) 61 | 62 | return metadata 63 | 64 | def _convertMetadata(self, parser, metadata): 65 | metadata['~format'] = parser.type 66 | 67 | if parser.title: 68 | metadata["title"] = parser.title 69 | 70 | if parser.artist: 71 | metadata["artist"] = parser.artist 72 | 73 | if parser.trackno: 74 | parts = parser.trackno.split("/") 75 | metadata["tracknumber"] = parts[0] 76 | if len(parts) > 1: 77 | metadata["totaltracks"] = parts[1] 78 | 79 | if parser.encoder: 80 | metadata["encodedby"] = parser.encoder 81 | 82 | if parser.video[0]: 83 | video = parser.video[0] 84 | metadata["~video"] = True 85 | 86 | if parser.audio[0]: 87 | audio = parser.audio[0] 88 | if audio.channels: 89 | metadata["~channels"] = audio.channels 90 | 91 | if audio.samplerate: 92 | metadata["~sample_rate"] = audio.samplerate 93 | 94 | if audio.language: 95 | metadata["language"] = audio.language 96 | 97 | if parser.length: 98 | metadata.length = parser.length * 1000 99 | elif video and video.length: 100 | metadata.length = parser.video[0].length * 1000 101 | 102 | def _save(self, filename, metadata): 103 | log.debug("Saving file %r", filename) 104 | pass 105 | 106 | 107 | class MatroskaFile(EnzymeFile): 108 | EXTENSIONS = [".mka", ".mkv", ".webm"] 109 | NAME = "Matroska" 110 | 111 | 112 | class MpegFile(EnzymeFile): 113 | EXTENSIONS = [".mpg", ".mpeg"] 114 | NAME = "MPEG" 115 | 116 | 117 | class RiffFile(EnzymeFile): 118 | EXTENSIONS = [".avi"] 119 | NAME = "RIFF" 120 | 121 | 122 | class QuickTimeFile(EnzymeFile): 123 | EXTENSIONS = [".mov", ".qt"] 124 | NAME = "QuickTime" 125 | -------------------------------------------------------------------------------- /plugins/videotools/script.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014 Philipp Wolfer 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License 7 | # as published by the Free Software Foundation; either version 2 8 | # of the License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software 17 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 18 | # 02110-1301, USA. 19 | 20 | VIDEO_EXTENSIONS = ['m4v', 'wmv', 'ogv', 'oggtheora'] 21 | 22 | 23 | def is_video(parser): 24 | """Returns true, if the file processed is a video file.""" 25 | if parser.context['~video'] or parser.context['~extension'] in VIDEO_EXTENSIONS: 26 | return "1" 27 | else: 28 | return "" 29 | 30 | 31 | def is_audio(parser): 32 | """Returns true, if the file processed is an audio file.""" 33 | if is_video(parser) == "1": 34 | return "" 35 | else: 36 | return "1" 37 | -------------------------------------------------------------------------------- /plugins/viewvariables/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | PLUGIN_NAME = u'View script variables' 4 | PLUGIN_AUTHOR = u'Sophist' 5 | PLUGIN_DESCRIPTION = u'''Display a dialog box listing the metadata variables for the track / file.''' 6 | PLUGIN_VERSION = '0.5' 7 | PLUGIN_API_VERSIONS = ['1.0'] 8 | PLUGIN_LICENSE = "GPL-2.0" 9 | PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" 10 | 11 | from PyQt4 import QtGui, QtCore 12 | try: 13 | from picard.util.tags import PRESERVED_TAGS 14 | except ImportError: 15 | from picard.file import File 16 | PRESERVED_TAGS = File._default_preserved_tags 17 | 18 | from picard.file import File 19 | from picard.track import Track 20 | from picard.ui.itemviews import BaseAction, register_file_action, register_track_action 21 | from picard.plugins.viewvariables.ui_variables_dialog import Ui_VariablesDialog 22 | 23 | 24 | class ViewVariables(BaseAction): 25 | NAME = 'View script variables' 26 | 27 | def callback(self, objs): 28 | obj = objs[0] 29 | files = self.tagger.get_files_from_objects(objs) 30 | if files: 31 | obj = files[0] 32 | dialog = ViewVariablesDialog(obj) 33 | dialog.exec_() 34 | 35 | 36 | class ViewVariablesDialog(QtGui.QDialog): 37 | 38 | def __init__(self, obj, parent=None): 39 | QtGui.QDialog.__init__(self, parent) 40 | self.ui = Ui_VariablesDialog() 41 | self.ui.setupUi(self) 42 | self.ui.buttonBox.accepted.connect(self.accept) 43 | self.ui.buttonBox.rejected.connect(self.reject) 44 | metadata = obj.metadata 45 | if isinstance(obj, File): 46 | self.setWindowTitle(_("File: %s") % obj.base_filename) 47 | elif isinstance(obj, Track): 48 | tn = metadata['tracknumber'] 49 | if len(tn) == 1: 50 | tn = u"0" + tn 51 | self.setWindowTitle(_("Track: %s %s ") % (tn, metadata['title'])) 52 | else: 53 | self.setWindowTitle(_("Variables")) 54 | self._display_metadata(metadata) 55 | 56 | def _display_metadata(self, metadata): 57 | keys = metadata.keys() 58 | keys.sort(key=lambda key: 59 | '0' + key if key in PRESERVED_TAGS and key.startswith('~') else 60 | '1' + key if key.startswith('~') else 61 | '2' + key 62 | ) 63 | media = hidden = album = False 64 | table = self.ui.metadata_table 65 | key_example, value_example = self.get_table_items(table, 0) 66 | self.key_flags = key_example.flags() 67 | self.value_flags = value_example.flags() 68 | table.setRowCount(len(keys) + 3) 69 | i = 0 70 | for key in keys: 71 | if key in PRESERVED_TAGS and key.startswith('~'): 72 | if not media: 73 | self.add_separator_row(table, i, _("File variables")) 74 | i += 1 75 | media = True 76 | elif key.startswith('~'): 77 | if not hidden: 78 | self.add_separator_row(table, i, _("Hidden variables")) 79 | i += 1 80 | hidden = True 81 | else: 82 | if not album: 83 | self.add_separator_row(table, i, _("Tag variables")) 84 | i += 1 85 | album = True 86 | 87 | key_item, value_item = self.get_table_items(table, i) 88 | i += 1 89 | key_item.setText(u"_" + key[1:] if key.startswith('~') else key) 90 | if key in metadata: 91 | value = dict.get(metadata, key) 92 | if len(value) == 1 and value[0] != '': 93 | value = value[0] 94 | else: 95 | value = repr(value) 96 | value_item.setText(value) 97 | 98 | def add_separator_row(self, table, i, title): 99 | key_item, value_item = self.get_table_items(table, i) 100 | font = key_item.font() 101 | font.setBold(True) 102 | key_item.setFont(font) 103 | key_item.setText(title) 104 | 105 | def get_table_items(self, table, i): 106 | key_item = table.item(i, 0) 107 | value_item = table.item(i, 1) 108 | if not key_item: 109 | key_item = QtGui.QTableWidgetItem() 110 | key_item.setFlags(self.key_flags) 111 | table.setItem(i, 0, key_item) 112 | if not value_item: 113 | value_item = QtGui.QTableWidgetItem() 114 | value_item.setFlags(self.value_flags) 115 | table.setItem(i, 1, value_item) 116 | return key_item, value_item 117 | 118 | vv = ViewVariables() 119 | register_file_action(vv) 120 | register_track_action(vv) 121 | -------------------------------------------------------------------------------- /plugins/viewvariables/ui_variables_dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'ui\variables_dialog.ui' 4 | # 5 | # Created: Wed Mar 26 06:58:04 2014 6 | # by: PyQt4 UI code generator 4.10.3 7 | # 8 | # WARNING! All changes made in this file will be lost! 9 | 10 | from PyQt4 import QtCore, QtGui 11 | 12 | try: 13 | _fromUtf8 = QtCore.QString.fromUtf8 14 | except AttributeError: 15 | def _fromUtf8(s): 16 | return s 17 | 18 | try: 19 | _encoding = QtGui.QApplication.UnicodeUTF8 20 | def _translate(context, text, disambig): 21 | return QtGui.QApplication.translate(context, text, disambig, _encoding) 22 | except AttributeError: 23 | def _translate(context, text, disambig): 24 | return QtGui.QApplication.translate(context, text, disambig) 25 | 26 | class Ui_VariablesDialog(object): 27 | def setupUi(self, VariablesDialog): 28 | VariablesDialog.setObjectName(_fromUtf8("VariablesDialog")) 29 | VariablesDialog.resize(600, 450) 30 | self.verticalLayout = QtGui.QVBoxLayout(VariablesDialog) 31 | self.verticalLayout.setObjectName(_fromUtf8("verticalLayout")) 32 | self.metadata_table = QtGui.QTableWidget(VariablesDialog) 33 | self.metadata_table.setAutoFillBackground(False) 34 | self.metadata_table.setSelectionMode(QtGui.QAbstractItemView.ContiguousSelection) 35 | self.metadata_table.setRowCount(1) 36 | self.metadata_table.setColumnCount(2) 37 | self.metadata_table.setObjectName(_fromUtf8("metadata_table")) 38 | item = QtGui.QTableWidgetItem() 39 | font = QtGui.QFont() 40 | font.setBold(True) 41 | font.setWeight(75) 42 | item.setFont(font) 43 | self.metadata_table.setHorizontalHeaderItem(0, item) 44 | item = QtGui.QTableWidgetItem() 45 | font = QtGui.QFont() 46 | font.setBold(True) 47 | font.setWeight(75) 48 | item.setFont(font) 49 | self.metadata_table.setHorizontalHeaderItem(1, item) 50 | item = QtGui.QTableWidgetItem() 51 | item.setFlags(QtCore.Qt.ItemIsSelectable|QtCore.Qt.ItemIsEnabled) 52 | self.metadata_table.setItem(0, 0, item) 53 | item = QtGui.QTableWidgetItem() 54 | item.setFlags(QtCore.Qt.ItemIsSelectable|QtCore.Qt.ItemIsEnabled) 55 | self.metadata_table.setItem(0, 1, item) 56 | self.metadata_table.horizontalHeader().setDefaultSectionSize(150) 57 | self.metadata_table.horizontalHeader().setSortIndicatorShown(False) 58 | self.metadata_table.horizontalHeader().setStretchLastSection(True) 59 | self.metadata_table.verticalHeader().setVisible(False) 60 | self.metadata_table.verticalHeader().setDefaultSectionSize(20) 61 | self.metadata_table.verticalHeader().setMinimumSectionSize(20) 62 | self.verticalLayout.addWidget(self.metadata_table) 63 | self.buttonBox = QtGui.QDialogButtonBox(VariablesDialog) 64 | self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok) 65 | self.buttonBox.setObjectName(_fromUtf8("buttonBox")) 66 | self.verticalLayout.addWidget(self.buttonBox) 67 | 68 | self.retranslateUi(VariablesDialog) 69 | QtCore.QMetaObject.connectSlotsByName(VariablesDialog) 70 | 71 | def retranslateUi(self, VariablesDialog): 72 | item = self.metadata_table.horizontalHeaderItem(0) 73 | item.setText(_("Variable")) 74 | item = self.metadata_table.horizontalHeaderItem(1) 75 | item.setText(_("Value")) 76 | __sortingEnabled = self.metadata_table.isSortingEnabled() 77 | self.metadata_table.setSortingEnabled(False) 78 | self.metadata_table.setSortingEnabled(__sortingEnabled) 79 | 80 | -------------------------------------------------------------------------------- /plugins/viewvariables/variables_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | VariablesDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 600 10 | 450 11 | 12 | 13 | 14 | 15 | 16 | 17 | false 18 | 19 | 20 | QAbstractItemView::ContiguousSelection 21 | 22 | 23 | 1 24 | 25 | 26 | 2 27 | 28 | 29 | 150 30 | 31 | 32 | false 33 | 34 | 35 | true 36 | 37 | 38 | false 39 | 40 | 41 | 20 42 | 43 | 44 | 20 45 | 46 | 47 | 48 | 49 | Variable 50 | 51 | 52 | 53 | 75 54 | true 55 | 56 | 57 | 58 | 59 | 60 | Value 61 | 62 | 63 | 64 | 75 65 | true 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | ItemIsSelectable|ItemIsEnabled 75 | 76 | 77 | 78 | 79 | ItemIsSelectable|ItemIsEnabled 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 88 | 89 | 90 | 91 | 92 | 93 | 94 | buttonBox 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import os 3 | import glob 4 | import sys 5 | import unittest 6 | 7 | # python 2 & 3 compatibility 8 | try: 9 | basestring 10 | except NameError: 11 | basestring = str 12 | 13 | from generate import * 14 | 15 | # The file that contains json data 16 | plugin_file = "plugins.json" 17 | 18 | # The directory which contains plugin files 19 | plugin_dir = "plugins" 20 | 21 | if sys.version_info[:2] == (2, 6): 22 | def assertIsInstance(self, obj, cls, msg=None): 23 | if not isinstance(obj, cls): 24 | self.fail('%s is not an instance of %r' % (repr(obj), cls)) 25 | 26 | unittest.TestCase.assertIsInstance = assertIsInstance 27 | 28 | 29 | class GenerateTestCase(unittest.TestCase): 30 | 31 | """Run tests""" 32 | 33 | def test_generate_json(self): 34 | """ 35 | Generates the json data from all the plugins 36 | and asserts that all plugins are accounted for. 37 | """ 38 | 39 | print("\n#########################################\n") 40 | 41 | build_json() 42 | 43 | # Load the json file 44 | with open(plugin_file, "r") as in_file: 45 | plugin_json = json.load(in_file)["plugins"] 46 | 47 | # All top level directories in plugin_dir 48 | plugin_folders = next(os.walk(plugin_dir))[1] 49 | 50 | # Number of entries in the json should be equal to the 51 | # number of folders in plugin_dir 52 | self.assertEqual(len(plugin_json), len(plugin_folders)) 53 | 54 | def test_generate_zip(self): 55 | """ 56 | Generates zip files for all folders and asserts 57 | that all folders are accounted for. 58 | """ 59 | 60 | print("\n\n#########################################\n") 61 | 62 | zip_files() 63 | 64 | # All zip files in plugin_dir 65 | plugin_zips = glob.glob(os.path.join(plugin_dir, "*.zip")) 66 | 67 | # All top level directories in plugin_dir 68 | plugin_folders = next(os.walk(plugin_dir))[1] 69 | 70 | # Number of folders should be equal to number of zips 71 | self.assertEqual(len(plugin_zips), len(plugin_folders)) 72 | 73 | def test_valid_json(self): 74 | """ 75 | Asserts that the json data contains all the fields 76 | for all the plugins. 77 | """ 78 | 79 | print("\n#########################################\n") 80 | 81 | build_json() 82 | 83 | # Load the json file 84 | with open(plugin_file, "r") as in_file: 85 | plugin_json = json.load(in_file)["plugins"] 86 | 87 | # All plugins should contain all required fields 88 | for module_name, data in plugin_json.items(): 89 | self.assertIsInstance(data['name'], basestring) 90 | self.assertIsInstance(data['api_versions'], list) 91 | self.assertIsInstance(data['author'], basestring) 92 | self.assertIsInstance(data['description'], basestring) 93 | self.assertIsInstance(data['version'], basestring) 94 | 95 | if __name__ == '__main__': 96 | unittest.main() 97 | --------------------------------------------------------------------------------