Repository: metabrainz/picard-plugins
Branch: 2.0
Commit: 1ad24cca7804
Files: 157
Total size: 2.3 MB
Directory structure:
gitextract_xqnp2_8g/
├── .github/
│ └── workflows/
│ └── test.yml
├── .gitignore
├── .prospector.yml
├── .pylintrc
├── README.md
├── build_ui.py
├── generate.py
├── get_plugin_data.py
├── plugins/
│ ├── abbreviate_artistsort/
│ │ └── abbreviate_artistsort.py
│ ├── acousticbrainz/
│ │ ├── __init__.py
│ │ ├── ui_options_acousticbrainz_tags.py
│ │ └── ui_options_acousticbrainz_tags.ui
│ ├── acousticbrainz_tonal-rhythm/
│ │ └── acousticbrainz_tonal-rhythm.py
│ ├── add_to_collection/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── manifest.py
│ │ ├── options.py
│ │ ├── override_module.py
│ │ ├── post_save_processor.py
│ │ ├── settings.py
│ │ └── ui_add_to_collection_options.py
│ ├── additional_artists_details/
│ │ ├── __init__.py
│ │ ├── docs/
│ │ │ └── README.md
│ │ ├── options_additional_artists_details.ui
│ │ └── ui_options_additional_artists_details.py
│ ├── additional_artists_variables/
│ │ └── additional_artists_variables.py
│ ├── addrelease/
│ │ └── addrelease.py
│ ├── albumartist_website/
│ │ └── albumartist_website.py
│ ├── albumartistextension/
│ │ └── albumartistextension.py
│ ├── amazon/
│ │ └── amazon.py
│ ├── bpm/
│ │ ├── __init__.py
│ │ ├── ui_options_bpm.py
│ │ └── ui_options_bpm.ui
│ ├── classical_extras/
│ │ ├── Readme.md
│ │ ├── __init__.py
│ │ ├── const.py
│ │ ├── options_classical_extras.ui
│ │ ├── suffixtree.py
│ │ └── ui_options_classical_extras.py
│ ├── classicdiscnumber/
│ │ └── classicdiscnumber.py
│ ├── collect_artists/
│ │ └── collect_artists.py
│ ├── compatible_TXXX/
│ │ └── compatible_TXXX.py
│ ├── critiquebrainz/
│ │ └── critiquebrainz.py
│ ├── cuesheet/
│ │ └── cuesheet.py
│ ├── decade/
│ │ └── __init__.py
│ ├── decode_cyrillic/
│ │ └── decode_cyrillic.py
│ ├── decode_greek_cyrillic/
│ │ └── decode_greek1253.py
│ ├── deezerart/
│ │ ├── __init__.py
│ │ ├── deezer/
│ │ │ ├── __init__.py
│ │ │ ├── client.py
│ │ │ └── obj.py
│ │ ├── options.py
│ │ └── options.ui
│ ├── discnumber/
│ │ └── discnumber.py
│ ├── enhanced_titles/
│ │ ├── __init__.py
│ │ ├── options_enhanced_titles.ui
│ │ └── ui_options_enhanced_titles.py
│ ├── fanarttv/
│ │ ├── __init__.py
│ │ ├── ui_options_fanarttv.py
│ │ └── ui_options_fanarttv.ui
│ ├── featartist/
│ │ └── featartist.py
│ ├── featartistsintitles/
│ │ └── featartistsintitles.py
│ ├── fix_tracknums/
│ │ └── fix_tracknums.py
│ ├── format_performer_tags/
│ │ ├── __init__.py
│ │ ├── docs/
│ │ │ ├── HISTORY.md
│ │ │ └── README.md
│ │ ├── ui_options_format_performer_tags.py
│ │ └── ui_options_format_performer_tags.ui
│ ├── genre_mapper/
│ │ ├── __init__.py
│ │ ├── options_genre_mapper.ui
│ │ └── ui_options_genre_mapper.py
│ ├── haikuattrs/
│ │ └── haikuattrs.py
│ ├── happidev_lyrics/
│ │ └── happidev_lyrics.py
│ ├── hyphen_unicode/
│ │ └── hyphen_unicode.py
│ ├── instruments/
│ │ └── instruments.py
│ ├── keep/
│ │ └── keep.py
│ ├── key_wheel_converter/
│ │ └── key_wheel_converter.py
│ ├── lastfm/
│ │ ├── __init__.py
│ │ ├── ui_options_lastfm.py
│ │ └── ui_options_lastfm.ui
│ ├── loadasnat/
│ │ └── loadasnat.py
│ ├── losslessfuncs/
│ │ └── __init__.py
│ ├── lrclib_lyrics/
│ │ ├── __init__.py
│ │ ├── option_lrclib_lyrics.py
│ │ └── option_lrclib_lyrics.ui
│ ├── matroska_tagger/
│ │ └── matroska_tagger.py
│ ├── mod/
│ │ └── __init__.py
│ ├── moodbars/
│ │ ├── __init__.py
│ │ ├── ui_options_moodbar.py
│ │ └── ui_options_moodbar.ui
│ ├── musixmatch/
│ │ ├── README
│ │ ├── __init__.py
│ │ └── ui_options_musixmatch.py
│ ├── no_release/
│ │ └── no_release.py
│ ├── non_ascii_equivalents/
│ │ └── non_ascii_equivalents.py
│ ├── padded/
│ │ └── padded.py
│ ├── papercdcase/
│ │ └── papercdcase.py
│ ├── performer_tag_replace/
│ │ ├── __init__.py
│ │ ├── options_performer_tag_replace.ui
│ │ └── ui_options_performer_tag_replace.py
│ ├── persistent_variables/
│ │ ├── __init__.py
│ │ └── ui_variables_dialog.py
│ ├── playlist/
│ │ └── playlist.py
│ ├── post_tagging_actions/
│ │ ├── __init__.py
│ │ ├── actions_status.py
│ │ ├── actions_status.ui
│ │ ├── docs/
│ │ │ └── guide.md
│ │ ├── options_post_tagging_actions.py
│ │ └── options_post_tagging_actions.ui
│ ├── release_type/
│ │ └── release_type.py
│ ├── releasetag_aggregations/
│ │ └── releasetag_aggregations.py
│ ├── remove_perfect_albums/
│ │ └── remove_perfect_albums.py
│ ├── reorder_sides/
│ │ └── reorder_sides.py
│ ├── replace_forbidden_symbols/
│ │ └── replace_forbidden_symbols.py
│ ├── replaygain2/
│ │ ├── __init__.py
│ │ ├── ui_options_replaygain2.py
│ │ └── ui_options_replaygain2.ui
│ ├── save_and_rewrite_header/
│ │ └── save_and_rewrite_header.py
│ ├── script_logger/
│ │ └── __init__.py
│ ├── search_engine_lookup/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── ui_options_search_engine_editor.py
│ │ ├── ui_options_search_engine_editor.ui
│ │ ├── ui_options_search_engine_lookup.py
│ │ └── ui_options_search_engine_lookup.ui
│ ├── 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
│ ├── submit_folksonomy_tags/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ └── ui_config.py
│ ├── submit_isrc/
│ │ ├── README.md
│ │ └── __init__.py
│ ├── tangoinfo/
│ │ ├── README.md
│ │ └── __init__.py
│ ├── theaudiodb/
│ │ ├── __init__.py
│ │ ├── ui_options_theaudiodb.py
│ │ └── ui_options_theaudiodb.ui
│ ├── titlecase/
│ │ └── titlecase.py
│ ├── tracks2clipboard/
│ │ └── tracks2clipboard.py
│ ├── viewvariables/
│ │ ├── __init__.py
│ │ ├── ui_variables_dialog.py
│ │ └── ui_variables_dialog.ui
│ ├── wikidata/
│ │ ├── __init__.py
│ │ ├── ui_options_wikidata.py
│ │ └── ui_options_wikidata.ui
│ └── workandmovement/
│ ├── __init__.py
│ └── roman.py
├── setup.cfg
└── test/
├── __init__.py
├── plugin_test_case.py
├── test_add_to_collection.py
├── test_doctest.py
├── test_generate.py
└── test_keep.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/test.yml
================================================
name: Test
on: [push, pull_request]
jobs:
unittest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install gettext
run: sudo apt-get install gettext
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install picard
- name: Test plugins
run: python -m unittest discover -v
================================================
FILE: .gitignore
================================================
# Generated by the script
plugins.json
plugins/*.zip
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
# Distribution / packaging / development
.venv/
.vscode/
.Python
env/
build/
================================================
FILE: .prospector.yml
================================================
# Configuration for prospector, mainly used by Codacy.
pep8:
# Please see comments in setup.cfg as to why we disable the below checks.
disable:
- E127
- E128
- E129
- E226
- E241
- E501
- W503
pyflakes:
disable:
# Undefined name. Ignore this since it otherwise detects the gettext
# helpers (_, ngettext, N_) as undefined. There seems to be no clean way
# to specify additional builtins for pyflakes with prospector.
# We also test for this with flake8 in a saner way.
- F821
# F401: Module imported but unused
# F841: Unused variables
# We have some valid cases for both, but PyFlakes does not allow to ignore
# them case by case. flake8 also checks this, so this is redundant.
- F401
- F841
# Ignore syntax errors as reported by PyFlakes since on Codacy this does
# not support Python 3 syntax.
# - F999
================================================
FILE: .pylintrc
================================================
[MASTER]
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-whitelist=
# Add files or directories to the ignore list. They should be base names, not
# paths.
ignore=CVS
# Add files or directories matching the regex patterns to the ignore list. The
# regex matches against base names, not paths.
ignore-patterns=ui_.*\.py
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=0
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Specify a configuration file.
#rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=all
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=consider-using-enumerate,
format-combined-specification,
return-in-init,
catching-non-exception,
bad-except-order,
unexpected-special-method-signature,
# Enforce list comprehensions
# Newline at EOF
raising-bad-type,
raising-non-exception,
format-needs-mapping,
invalid-all-object,
bad-super-call,
nonexistent-operator,
missing-kwoa,
missing-format-argument-key,
init-is-generator,
access-member-before-definition,
used-before-assignment,
redundant-keyword-arg,
assert-on-tuple,
assignment-from-no-return,
expression-not-assigned,
misplaced-bare-raise,
redefined-argument-from-local,
not-in-loop,
bad-exception-context,
unidiomatic-typecheck,
no-staticmethod-decorator,
nonlocal-and-global,
confusing-with-statement,
global-variable-undefined,
global-variable-not-assigned,
inconsistent-mro,
no-classmethod-decorator,
nonlocal-without-binding,
duplicate-bases,
duplicate-argument-name,
duplicate-key,
useless-else-on-loop,
arguments-differ,
logging-too-many-args,
too-few-format-args,
bad-format-string-key,
invalid-sequence-index,
inherit-non-class,
bad-format-string,
invalid-format-index,
invalid-star-assignment-target,
no-method-argument,
no-value-for-parameter,
missing-format-attribute,
logging-too-few-args,
too-few-format-args,
mixed-format-string,
# Old style class
logging-format-truncated,
truncated-format-string,
notimplemented-raised,
# Builtin redefined
function-redefined,
reimported,
repeated-keyword,
lost-exception,
return-outside-function,
return-arg-in-generator,
non-iterator-returned,
method-hidden,
too-many-star-expressions,
trailing-whitespace,
unexpected-keyword-arg,
missing-format-string-key,
unnecessary-lambda,
unnecessary-pass,
unreachable,
logging-unsupported-format,
bad-format-character,
unused-import,
exec-used,
pointless-statement,
pointless-string-statement,
undefined-all-variable,
misplaced-future,
continue-in-finally,
invalid-slots,
invalid-slice-index,
invalid-slots-object,
star-needs-assignment-target,
global-at-module-level,
yield-outside-function,
mixed-indentation,
non-parent-init-called,
bare-except,
#no-self-use,
dangerous-default-value,
arguments-differ,
signature-differs,
duplicate-except,
abstract-class-instantiated,
binary-op-exception,
undefined-variable
[REPORTS]
# Python expression which should return a note less than 10 (10 is the highest
# note). You have access to the variables errors warning, statement which
# respectively contain the number of errors / warnings messages and the total
# number of statements analyzed. This is used by the global evaluation report
# (RP0004).
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
#msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
output-format=text
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )??$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# List of optional constructs for which whitespace checking is disabled. `dict-
# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
# `empty-line` allows space-only lines.
no-space-check=trailing-comma,
dict-separator
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. Available dictionaries: none. To make it working
# install python-enchant package..
spelling-dict=
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to indicated private dictionary in
# --spelling-private-dict-file option instead of raising a message.
spelling-store-unknown-words=no
[SIMILARITIES]
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=no
# Minimum lines number of a similarity.
min-similarity-lines=4
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=_, N_, ngettext, gettext_countries,
gettext_attributes, pgettext_attributes
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored. Default to name
# with leading underscore.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
[LOGGING]
# Format style used to check logging format string. `old` means using %
# formatting, while `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style.
#class-attribute-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style.
#variable-rgx=
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis. It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
[CLASSES]
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[IMPORTS]
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=optparse,tkinter.tix
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled).
ext-import-graph=
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled).
import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
[DESIGN]
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement.
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception".
overgeneral-exceptions=Exception
================================================
FILE: README.md
================================================
# MusicBrainz Picard Plugins
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.
> [!NOTE]
> This repository is for Picard v1 and v2 plugins only. Plugins for Picard v3 are managed through the [Picard Plugins Registry](https://github.com/metabrainz/picard-plugins-registry).
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.
## Development Notes
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.
================================================
FILE: build_ui.py
================================================
#!/usr/bin/env python3
import glob
import os
from PyQt5 import uic
os.chdir(os.path.dirname(__file__))
plugin_dir = os.path.relpath('plugins')
def compile_ui(uifile, pyfile):
if newer(uifile, pyfile):
print("compiling %s -> %s" % (uifile, pyfile))
with open(pyfile, "w") as out:
uic.compileUi(uifile, out)
else:
print("skipping %s -> %s: up to date" % (uifile, pyfile))
def newer(file1, file2):
"""Returns True, if file1 has been modified after file2
"""
if not os.path.exists(file2):
return True
return os.path.getmtime(file1) > os.path.getmtime(file2)
if __name__ == '__main__':
for uifile in glob.glob(os.path.join(plugin_dir, '*/*.ui')):
pyfile = os.path.splitext(os.path.basename(uifile))[0] + '.py'
pyfile = os.path.join(os.path.dirname(uifile), pyfile)
compile_ui(uifile, pyfile)
================================================
FILE: generate.py
================================================
#!/usr/bin/env python3
from __future__ import print_function
import argparse
import os
import json
import zipfile
from hashlib import md5
from subprocess import check_call
from get_plugin_data import get_plugin_data
VERSION_TO_BRANCH = {
None: '2.0',
'1.0': '1.0',
'2.0': '2.0',
}
def build_json(dest_dir):
"""
Traverse the plugins directory to generate json data.
"""
plugins = {}
# All top level directories in plugin_dir are plugins
for dirname in next(os.walk(plugin_dir))[1]:
files = {}
data = {}
if dirname in [".git"]:
continue
dirpath = os.path.join(plugin_dir, dirname)
for root, dirs, filenames in os.walk(dirpath):
for filename in filenames:
ext = os.path.splitext(filename)[1]
if ext not in [".pyc"]:
file_path = os.path.join(root, filename)
with open(file_path, "rb") as md5file:
md5Hash = md5(md5file.read()).hexdigest()
files[file_path.split(os.path.join(dirpath, ''))[1]] = md5Hash
if ext in ['.py'] and not data:
data = get_plugin_data(os.path.join(plugin_dir, dirname, filename))
if files and data:
print("Added: " + dirname)
data['files'] = files
plugins[dirname] = data
out_path = os.path.join(dest_dir, plugin_file)
with open(out_path, "w") as out_file:
json.dump({"plugins": plugins}, out_file, sort_keys=True, indent=2)
def zip_files(dest_dir):
"""
Zip up plugin folders
"""
for dirname in next(os.walk(plugin_dir))[1]:
archive_path = os.path.join(dest_dir, dirname)
archive = zipfile.ZipFile(archive_path + ".zip", "w")
dirpath = os.path.join(plugin_dir, dirname)
plugin_files = []
for root, dirs, filenames in os.walk(dirpath):
for filename in filenames:
file_path = os.path.join(root, filename)
plugin_files.append(file_path)
if (len(plugin_files) == 1
and os.path.basename(plugin_files[0]) != '__init__.py'):
# There's only one file, put it directly into the zipfile
archive.write(plugin_files[0],
os.path.basename(plugin_files[0]),
compress_type=zipfile.ZIP_DEFLATED)
else:
for filename in plugin_files:
# Preserve the folder structure relative to plugin_dir
# in the zip file
name_in_zip = os.path.join(os.path.relpath(filename,
plugin_dir))
archive.write(filename,
name_in_zip,
compress_type=zipfile.ZIP_DEFLATED)
print("Created: " + dirname + ".zip")
# The file that contains json data
plugin_file = "plugins.json"
# The directory which contains plugin files
plugin_dir = "plugins"
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Generate plugin files for Picard website.')
parser.add_argument('version', nargs='?', help="Build output files for the specified version")
parser.add_argument('--build_dir', default="build", help="Path for the build output. DEFAULT = %(default)s")
parser.add_argument('--pull', action='store_true', dest='pull', help="Pulls the remote origin and updates the files before building")
parser.add_argument('--no-zip', action='store_false', dest='zip', help="Do not generate the zip files in the build output")
parser.add_argument('--no-json', action='store_false', dest='json', help="Do not generate the json file in the build output")
args = parser.parse_args()
check_call(["git", "checkout", "-q", VERSION_TO_BRANCH[args.version], '--', 'plugins'])
dest_dir = os.path.abspath(os.path.join(args.build_dir, args.version or ''))
if not os.path.exists(dest_dir):
os.makedirs(dest_dir)
if args.pull:
check_call(["git", "pull", "-q"])
if args.json:
build_json(dest_dir)
if args.zip:
zip_files(dest_dir)
================================================
FILE: get_plugin_data.py
================================================
# -*- coding: utf-8 -*-
from __future__ import print_function
import ast
KNOWN_DATA = [
'PLUGIN_NAME',
'PLUGIN_AUTHOR',
'PLUGIN_VERSION',
'PLUGIN_API_VERSIONS',
'PLUGIN_LICENSE',
'PLUGIN_LICENSE_URL',
'PLUGIN_DESCRIPTION',
]
def get_plugin_data(filepath):
"""Parse a python file and return a dict with plugin metadata"""
data = {}
with open(filepath, 'r', encoding='utf-8') as plugin_file:
source = plugin_file.read()
try:
root = ast.parse(source, filepath)
except:
print("Cannot parse " + filepath)
raise
for node in ast.iter_child_nodes(root):
if isinstance(node, ast.Assign) and len(node.targets) == 1:
target = node.targets[0]
if (isinstance(target, ast.Name)
and isinstance(target.ctx, ast.Store)
and target.id in KNOWN_DATA):
name = target.id.replace('PLUGIN_', '', 1).lower()
if name not in data:
try:
data[name] = ast.literal_eval(node.value)
except ValueError:
print('Cannot evaluate value in '
+ filepath + ':' +
ast.dump(node))
return data
================================================
FILE: plugins/abbreviate_artistsort/abbreviate_artistsort.py
================================================
# -*- coding: utf-8 -*-
# This is the Sort Multivalue Tags plugin for MusicBrainz Picard.
# Copyright (C) 2013 Sophist
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
PLUGIN_NAME = "Abbreviate artist-sort"
PLUGIN_AUTHOR = "Sophist"
PLUGIN_DESCRIPTION = '''Abbreviate Artist-Sort and Album-Artist-Sort Tags.
e.g. "Vivaldi, Antonio" becomes "Vivaldi, A."
This is particularly useful for classical albums that can have a long list of artists.
%artistsort% is abbreviated into %_artistsort_abbrev% and
%albumartistsort% is abbreviated into %_albumartistsort_abbrev%.'''
PLUGIN_VERSION = "0.4.1"
PLUGIN_API_VERSIONS = ["1.0", "2.0"]
PLUGIN_LICENSE = "GPL-2.0-or-later"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"
from picard import log
from picard.metadata import register_track_metadata_processor
# NOTE: This plugin will not work consistently if you have not enabled the 'Standardize Artist Names' option!
# The algorithm for this is complicated because the tags can contain multiple names separated by various characters
# As an example from http://musicbrainz.org/release/6c0cfb20-2606-46c1-9306-ee5e7cb5bfdf
# Sorted: Vivaldi, Antonio, Caldara, Antonio; Queyras, Jean-Guihen, Kallweit, Georg, Akademie für Alte Musik Berlin
# Unsorted: Antonio Vivaldi, Antonio Caldara; Jean-Guihen Queyras, Georg Kallweit, Akademie für Alte Musik Berlin
# As you can see, in unsorted, names are separated by ',' as well as ';' but could be e.g. 'feat:'
# The only known is that in sorted, surname is separated from forename(s) by a ','
# It is further complicated by non-latin (e.g. japanese) script artist names
# where unsorted is in local script, but sorted can be in latin, and names can be in local locale or general.
# If the names are just reversed, and we shift Unsorted to the right by one character (for the ',' in sorted),
# then the punctuation should start to match up:
# Sorted: Vivaldi, Antonio, Caldara, Antonio; Queyras, Jean-Guihen, Kallweit, Georg, Akademie für Alte Musik Berlin
# Unsorted: Antonio Vivaldi, Antonio Caldara; Jean-Guihen Queyras, Georg Kallweit, Akademie für Alte Musik Berlin
# ^
# Of course if we have non-latin or locale names, alignment could be way off:
# Sorted: Verdi, Giuseppe, Vivaldi, Antonio
# Unsorted: Joe Green, Antonio Vivaldi
# In the absence of an array version of the tags (PR pending)
# we need to process the tag and sorted tag together in a special way as follows:
#
# 1. Look for the first ',' in sorted and tentatively set surname to the string up to that point.
# Case a. Sorted: Stuff, ...
# Unsorted: Stuff, ...
# Case b. Sorted: Surname, Forename(s)...
# Unsorted: Forename(s) Surname...
# Special case: Sorted: Major, Major...
# Unsorted: Major Major...
# Case c. Sorted: Stuff; Surname, Forename(s)...
# Unsorted: Stuff; Forename(s) Surname...
# Case d. Sorted: Latin Surname, Latin Forename(s)...
# Unsorted: Foreign...
# Case e. Sorted: Beatles, The...
# Unsorted: The Beatles...
# 2. Locate surname in unsorted:
# Case a. unsorted starts with surname - move both to new strings
# Case b. surname can be found in unsorted - forename(s) are what is before and match beginning of rest
# Case c. If first word is same in sorted and unsorted, move words that match to new strings, then treat as b.
# Case d. Try to handle without abbreviating and get to next name which might not be foreign
_abbreviate_tags = [
('albumartistsort', 'albumartist', '~albumartistsort_abbrev'),
('artistsort', 'artist', '~artistsort_abbrev'),
]
_prefixes = ["A", "The"]
_split = ", "
_abbreviate_cache = {}
def abbreviate_artistsort(tagger, metadata, track, release):
for sortTag, unsortTag, sortTagNew in _abbreviate_tags:
if not (sortTag in metadata and unsortTag in metadata):
continue
sorts = list(metadata.getall(sortTag))
unsorts = list(metadata.getall(unsortTag))
for i in range(0, min(len(sorts), len(unsorts))):
sort = sorts[i]
log.debug("%s: Trying to abbreviate '%s'." % (PLUGIN_NAME, sort))
if sort in _abbreviate_cache:
log.debug(" Using abbreviation found in cache: '%s'." % (_abbreviate_cache[sort]))
sorts[i] = _abbreviate_cache[sort]
continue
unsort = unsorts[i]
new_sort = ""
new_unsort = ""
while len(sort) > 0 and len(unsort) > 0:
if not _split in sort:
log.debug(" Ending without separator '%s' - moving '%s'." % (_split, sort))
new_sort += sort
new_unsort += unsort
sort = unsort = ""
continue
surname, rest = sort.split(_split, 1)
if rest == "":
log.debug(" Ending with separator '%s' - moving '%s'." % (_split, surname))
new_sort += sort
new_unsort += unsort
sort = unsort = ""
continue
# Move leading whitespace
new_unsort += unsort[0:len(unsort) - len(unsort.lstrip())]
unsort = unsort.lstrip()
# Sorted: Stuff, ...
# Unsorted: Stuff, ...
temp = surname + _split
l = len(temp)
if unsort[:l] == temp:
log.debug(" No forename - moving '%s'." % (surname))
new_sort += temp
new_unsort += temp
sort = sort[l:]
unsort = unsort[l:]
continue
# Sorted: Stuff; Surname, Forename(s)...
# Unsorted: Stuff; Forename(s) Surname...
# Move matching words plus white-space one by one
if unsort.find(' ' + surname) == -1:
while surname.split(None, 1)[0] == unsort.split(None, 1)[0]:
x = unsort.split(None, 1)[0]
log.debug(" Moving matching word '%s'." % (x))
new_sort += x
new_unsort += x
surname = surname[len(x):]
unsort = unsort[len(x):]
new_sort += surname[0:len(surname) - len(surname.lstrip())]
surname = surname.lstrip()
new_unsort += unsort[0:len(unsort) - len(unsort.lstrip())]
unsort = unsort.lstrip()
# If we still can't find surname then we are up a creek...
pos = unsort.find(' ' + surname)
if pos == -1:
log.debug(
_("%s: Track %s: Unable to abbreviate surname '%s' - not matched in unsorted %s: '%s'."),
PLUGIN_NAME,
metadata['tracknumber'],
surname,
unsortTag,
unsort[i],
)
log.warning(" Could not match surname '%s' in remaining unsorted: %s" % (surname, unsort))
break
# Sorted: Surname, Forename(s)...
# Unsorted: Forename(s) Surname...
forename = unsort[:pos]
if rest[:len(forename)] != forename:
log.debug(
_("%s: Track %s: Unable to abbreviate surname (%s) - forename (%s) not matched in unsorted %s: '%s'."),
PLUGIN_NAME,
metadata['tracknumber'],
surname,
forename,
unsortTag,
unsort[i],
)
log.warning(" Could not match forename (%s) for surname (%s) in remaining unsorted (%s):" % (forename, surname, unsort))
break
inits = ' '.join([x[0] + '.' for x in forename.split()])
# Sorted: Beatles, The...
# Unsorted: The Beatles...
if forename in _prefixes:
inits = forename
new_sort += surname + _split + inits
sort = rest[len(forename):]
new_sort += sort[0:len(sort) - len(sort[1:].lstrip())]
sort = sort[1:].lstrip()
new_unsort += forename
unsort = unsort[len(forename):]
new_unsort += unsort[0:len(unsort) - len(unsort.lstrip())]
unsort = unsort.lstrip()
new_unsort += surname
unsort = unsort[len(surname):]
new_unsort += unsort[0:len(unsort) - len(unsort[1:].lstrip())]
unsort = unsort[1:].lstrip()
if forename != inits:
log.debug(
_("%s: Abbreviated surname (%s, %s) to (%s, %s) in '%s'."),
PLUGIN_NAME,
surname,
forename,
surname,
inits,
sortTag,
)
log.debug("Abbreviated (%s, %s) to (%s, %s)." % (surname, forename, surname, inits))
else: # while loop ended without a break i.e. no errors
if unsorts[i] != new_unsort:
log.error(
_("%s: Track %s: Logic error - mangled %s from '%s' to '%s'."),
PLUGIN_NAME,
metadata['tracknumber'],
unsortTag,
unsorts[i],
new_unsort,
)
log.warning("Error: Unsorted text for %s has changed from '%s' to '%s'!" % (unsortTag, unsorts[i], new_unsort))
_abbreviate_cache[sorts[i]] = new_sort
log.debug(" Abbreviated and cached (%s) as (%s)." % (sorts[i], new_sort))
if sorts[i] != new_sort:
log.debug(_("%s: Abbreviated tag '%s' to '%s'."),
PLUGIN_NAME,
sorts[i],
new_sort,
)
sorts[i] = new_sort
metadata[sortTagNew] = sorts
register_track_metadata_processor(abbreviate_artistsort)
================================================
FILE: plugins/acousticbrainz/__init__.py
================================================
# -*- coding: utf-8 -*-
# AcousticBrainz plugin for Picard
#
# Copyright (C) 2021 Wargreen The low level subset include: These settings will determine how the Additional Artists Details plugin operates. Please visit the repository on GitHub for additional information. This option determines whether or not details are retrieved for all track artists on the release. If you are only interested in details for the album artists then this should be disabled, thus significantly reducing the number of additional calls made to the MusicBrainz api and reducing the time required to load a release. Album artists are always processed. This option determines whether or not County and Municipality information is included in the artist location variables created. Regardless of this setting, this information will be included if a County or Municipality is the area specified for an artist. should be selected otherwise this section will not run The naming style for \'artist\' tags is set in the main Picard Options->Metadata section Work-artist / performer naming options "Work-artists" are types such as composer, writer, arranger and lyricist who belong to the MusicBrainz Work-Artist relationship "Performers" are types such as performer and conductor who belong to the MusicBrainz Recording-Artist relationship This section does not change the contents of "artist" or "album artist" tags - it only affects writer (composer etc.) and peformer tags, by using as-credited/alias names from the artist data for the release.
By default, only simple mood and genre information is saved, but the plugin can
be configured to include all highlevel data.
Based on code from Andrew Cook, Sambhav Kothari
WARNING: Experimental plugin. All guarantees voided by use.'''
PLUGIN_LICENSE = "GPL-2.0"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt"
PLUGIN_VERSION = "2.2.3"
PLUGIN_API_VERSIONS = ["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7"]
# Plugin configuration
# =============================================================================
ACOUSTICBRAINZ_HOST = "acousticbrainz.org"
ACOUSTICBRAINZ_PORT = 80
# Subset of the low level data to add as tags.
# Represent the data as nested dicts.
SUBLOWLEVEL_SUBSET = {"rhythm": {"bpm": None},
"tonal": {"chords_changes_rate": None,
"chords_key": None,
"chords_scale": None,
"key_key": None,
"key_scale": None}}
# Imports
# =============================================================================
from functools import partial
from json import dumps as dump_json
from picard import (
config,
log,
)
from picard.metadata import (
register_album_metadata_processor,
register_track_metadata_processor,
)
from picard.ui.options import (
OptionsPage,
register_options_page,
)
from picard.webservice import ratecontrol
from picard.plugins.acousticbrainz.ui_options_acousticbrainz_tags import Ui_AcousticBrainzOptionsPage
ratecontrol.set_minimum_delay((ACOUSTICBRAINZ_HOST, ACOUSTICBRAINZ_PORT), 1000)
# Constants
# =============================================================================
LOWLEVEL = "lowlevel"
HIGHLEVEL = "highlevel"
# Logging utilities
# -------------------------------------------------------------------------
def log_msg(logger, text, *args):
logger("%s: " + text, PLUGIN_NAME, *args)
def debug(*args):
log_msg(log.debug, *args)
def warning(*args):
log_msg(log.warning, *args)
def error(*args):
log_msg(log.error, *args)
# TrackDataProcessor class
# =============================================================================
# (used to apply AcousticBrainz data to Track metadata)
class TrackDataProcessor:
def __init__(self, recording_id, metadata, level, data, files=None):
self.recording_id = recording_id
self.metadata = metadata
self.level = level
self.data = self._extract_data(data)
self.files = files
self.do_simplemood = config.setting["acousticbrainz_add_simplemood"]
self.simplemood_tagname = config.setting["acousticbrainz_simplemood_tagname"]
self.do_simplegenre = config.setting["acousticbrainz_add_simplegenre"]
self.simplegenre_tagname = config.setting["acousticbrainz_simplegenre_tagname"]
self.do_keybpm = config.setting["acousticbrainz_add_keybpm"]
self.do_fullhighlevel = config.setting["acousticbrainz_add_fullhighlevel"]
self.do_sublowlevel = config.setting["acousticbrainz_add_sublowlevel"]
# Logging utilities
# -------------------------------------------------------------------------
def log(self, logger, text, *args):
log_msg(logger, "[%s: %s] " + text, self.shortid, self.title, *args)
def debug(self, *args):
self.log(log.debug, *args)
def warning(self, *args):
self.log(log.warning, *args)
def error(self, *args):
self.log(log.error, *args)
# Read-only properties
# -------------------------------------------------------------------------
@property
def shortid(self):
return self.recording_id[:8]
@property
def title(self):
return self.metadata["title"]
# Callback
# -------------------------------------------------------------------------
def process(self):
if not self.data:
self.warning('No %s data for track %s', self.level, self.recording_id)
if self.level == HIGHLEVEL:
if self.do_simplemood:
self.process_simplemood()
if self.do_simplegenre:
self.process_simplegenre()
if self.do_fullhighlevel:
self.process_fullhighlevel()
if self.level == LOWLEVEL:
if self.do_keybpm:
self.process_keybpm()
if self.do_sublowlevel:
self.process_sublowlevel()
# Processing helper methods
# -------------------------------------------------------------------------
def _extract_data(self, data):
if not self.recording_id in data:
return {}
if self.level == LOWLEVEL:
return data[self.recording_id]["0"]
elif self.level == HIGHLEVEL:
return data[self.recording_id]["0"]["highlevel"]
def filter_data(self, data, subset):
result = {}
for key, value in subset.items():
if key in data:
if isinstance(value, dict):
self.debug("filter_data : traversing %s", key)
result[key] = self.filter_data(data[key], value)
else:
self.debug("filter_data : adding result %s", key)
result[key] = data[key]
else:
self.debug("filter_data : Subset key %s not found in data", key)
continue
self.debug("filter_data : result : %s", result)
return result
def update_metadata(self, name, values):
self.metadata[name] = values
if self.files:
for file in self.files:
file.metadata[name] = values
# Processing methods
# -------------------------------------------------------------------------
# (fill metadata with fetched data)
def process_simplemood(self):
self.debug("processing simplemood data")
mood_tagname = self.simplemood_tagname;
moods = []
for classifier, data in self.data.items():
if "value" in data:
value = data["value"]
inverted = value.startswith("not_")
if classifier.startswith("mood_") and not inverted:
moods.append(value)
self.update_metadata(mood_tagname, moods)
def process_simplegenre(self):
self.debug("processing simplegenre data")
genre_tagname = self.simplegenre_tagname;
genres = []
for classifier, data in self.data.items():
if "value" in data:
value = data["value"]
inverted = value.startswith("not_")
if classifier.startswith("genre_") and not inverted:
genres.append(value)
self.update_metadata(genre_tagname, genres)
def process_fullhighlevel(self):
self.debug("processing fullhighlevel data")
f_count, c_count = 0, 0
for classifier, data in self.data.items():
classifier = classifier.lower()
if "all" in data:
c_count += 1
for feature, proba in data["all"].items():
feature = feature.lower()
f_count += 1
self.update_metadata("ab:hi:{}:{}".format(classifier, feature), dump_json(proba))
else:
self.warning("fullhighlevel : ignored invalid classifier data (%s)", classifier)
self.debug("fullhighlevel : parsed %d features from %d classifiers", f_count, c_count)
def process_keybpm(self):
self.debug("processing keybpm data")
if "tonal" in self.data:
tonal_data = self.data["tonal"]
if "key_key" in tonal_data:
key = tonal_data["key_key"]
if "key_scale" in tonal_data:
if tonal_data["key_scale"] == "minor":
key += "m"
self.update_metadata('key', key)
self.debug("track '%s' is in key %s", self.title, key)
if "rhythm" in self.data:
rhythm_data = self.data["rhythm"]
if "bpm" in rhythm_data:
bpm = int(rhythm_data["bpm"] + 0.5)
self.update_metadata('bpm', bpm)
self.debug("keybpm : Track '%s' has %s bpm", self.title, bpm)
def process_sublowlevel(self):
self.debug("processing sublowlevel data")
subset = SUBLOWLEVEL_SUBSET
filtered_data = self.filter_data(self.data, subset)
f_count, c_count = 0, 0
for classifier, data in filtered_data.items():
classifier = classifier.lower()
c_count += 1
for feature, proba in data.items():
feature = feature.lower()
f_count += 1
self.update_metadata("ab:lo:{}:{}".format(classifier, feature), proba)
self.debug("sublowlevel : parsed %d features from %d classifiers", f_count, c_count)
class AcousticBrainzRequest:
MAX_BATCH_SIZE = 25
def __init__(self, webservice, recording_ids):
self.webservice = webservice
self.recording_ids = recording_ids
def request_highlevel(self, callback):
self._batch('high-level', self.recording_ids, callback, {})
def request_lowlevel(self, callback):
self._batch('low-level', self.recording_ids, callback, {})
def _batch(self, action, recording_ids, callback, result, response=None, reply=None, error=None):
if response and not error:
self._merge_results(result, response)
if not recording_ids or error:
callback(result, error)
return
batch = recording_ids[:self.MAX_BATCH_SIZE]
recording_ids = recording_ids[self.MAX_BATCH_SIZE:]
self._do_request(action, batch,
callback=partial(self._batch, action, recording_ids, callback, result))
def _do_request(self, action, recording_ids, callback):
self.webservice.get(
ACOUSTICBRAINZ_HOST,
ACOUSTICBRAINZ_PORT,
'/api/v1/%s' % action,
callback,
priority=True,
parse_response_type='json',
queryargs=self._get_query_args(action, recording_ids)
)
def _get_query_args(self, action, recording_ids):
queryargs = {
'recording_ids': ';'.join(recording_ids),
}
if action == 'high-level':
queryargs['map_classes'] = 'true'
return queryargs
def _merge_results(self, full, new):
mapping = new.get('mbid_mapping', {})
new = {mapping.get(k, k): v for (k, v) in new.items() if k != 'mbid_mapping'}
full.update(new)
# Plugin class
# =============================================================================
# (provides track processing callback)
class AcousticBrainzPlugin:
result_cache = {
LOWLEVEL: {},
HIGHLEVEL: {},
}
def process_album(self, album, metadata, release):
debug('Processing album %s', album.id)
recording_ids = self.get_recording_ids(release)
self.run_requests(album, recording_ids, self.album_callback)
def process_track(self, album, metadata, track_node, release_node):
# Run requests for standalone recordings
if not release_node and 'id' in track_node:
recording_id = track_node['id']
debug('Processing recording %s', recording_id)
self.run_requests(album, [recording_id], partial(self.nat_callback, recording_id))
# Apply metadata changes for already loaded results for album tracks
elif 'recording' in track_node:
recording_id = track_node['recording']['id']
for level in (LOWLEVEL, HIGHLEVEL):
result = self.result_cache[level].get(album.id)
if result:
self.apply_result(recording_id, metadata, level, result)
def run_requests(self, album, recording_ids, callback):
request = AcousticBrainzRequest(album.tagger.webservice, recording_ids)
if self.do_highlevel:
album._requests += 1
request.request_highlevel(partial(callback, HIGHLEVEL, album))
if self.do_lowlevel:
album._requests += 1
request.request_lowlevel(partial(callback, LOWLEVEL, album))
@property
def do_highlevel(self):
return (config.setting["acousticbrainz_add_simplemood"]
or config.setting["acousticbrainz_add_simplegenre"]
or config.setting["acousticbrainz_add_fullhighlevel"])
@property
def do_lowlevel(self):
return (config.setting["acousticbrainz_add_keybpm"]
or config.setting["acousticbrainz_add_sublowlevel"])
def get_recording_ids(self, release):
return [track['recording']['id'] for track in self.iter_tracks(release)]
@staticmethod
def iter_tracks(release):
for media in release['media']:
if 'pregap' in media:
yield media['pregap']
if 'tracks' in media:
yield from media['tracks']
if 'data-tracks' in media:
yield from media['data-tracks']
def album_callback(self, level, album, result=None, error=None):
if not error:
# Store the result, the actual processing will be done by the
# track metadata processor.
self.result_cache[level][album.id] = result
album.run_when_loaded(partial(self.clear_cache, level, album))
album._requests -= 1
album._finalize_loading(error)
def nat_callback(self, recording_id, level, album, result=None, error=None):
for track in album.tracks:
if track.id == recording_id:
self.apply_result(track.id, track.metadata, level, result, files=track.files)
def apply_result(self, recording_id, metadata, level, result, files=None):
debug('Updating recording %s with %s results', recording_id, level)
processor = TrackDataProcessor(recording_id, metadata, level, result, files=files)
processor.process()
def clear_cache(self, level, album):
try:
del self.result_cache[level][album.id]
except KeyError:
pass
# Plugin options page
# =============================================================================
# (define plugin options and link with user interface)
class AcousticBrainzOptionsPage(OptionsPage):
NAME = "acousticbrainz_tags"
TITLE = "AcousticBrainz tags"
PARENT = "plugins"
options = [
config.BoolOption("setting", "acousticbrainz_add_simplemood", True),
config.TextOption("setting", "acousticbrainz_simplemood_tagname", "ab:mood"),
config.BoolOption("setting", "acousticbrainz_add_simplegenre", True),
config.TextOption("setting", "acousticbrainz_simplegenre_tagname", "ab:genre"),
config.BoolOption("setting", "acousticbrainz_add_keybpm", False),
config.BoolOption("setting", "acousticbrainz_add_fullhighlevel", False),
config.BoolOption("setting", "acousticbrainz_add_sublowlevel", False)
]
def __init__(self, parent=None):
super(AcousticBrainzOptionsPage, self).__init__(parent)
self.ui = Ui_AcousticBrainzOptionsPage()
self.ui.setupUi(self)
def load(self):
setting = config.setting
self.ui.add_simplemood.setChecked(setting["acousticbrainz_add_simplemood"])
self.ui.simplemood_tagname.setText(setting["acousticbrainz_simplemood_tagname"])
self.ui.add_simplegenre.setChecked(setting["acousticbrainz_add_simplegenre"])
self.ui.simplegenre_tagname.setText(setting["acousticbrainz_simplegenre_tagname"])
self.ui.add_fullhighlevel.setChecked(setting["acousticbrainz_add_fullhighlevel"])
self.ui.add_keybpm.setChecked(setting["acousticbrainz_add_keybpm"])
self.ui.add_sublowlevel.setChecked(setting["acousticbrainz_add_sublowlevel"])
def save(self):
setting = config.setting
setting["acousticbrainz_add_simplemood"] = self.ui.add_simplemood.isChecked()
setting["acousticbrainz_simplemood_tagname"] = str(self.ui.simplemood_tagname.text())
setting["acousticbrainz_add_simplegenre"] = self.ui.add_simplegenre.isChecked()
setting["acousticbrainz_simplegenre_tagname"] = str(self.ui.simplegenre_tagname.text())
setting["acousticbrainz_add_keybpm"] = self.ui.add_keybpm.isChecked()
setting["acousticbrainz_add_fullhighlevel"] = self.ui.add_fullhighlevel.isChecked()
setting["acousticbrainz_add_sublowlevel"] = self.ui.add_sublowlevel.isChecked()
plugin = AcousticBrainzPlugin()
register_album_metadata_processor(plugin.process_album)
register_track_metadata_processor(plugin.process_track)
register_options_page(AcousticBrainzOptionsPage)
================================================
FILE: plugins/acousticbrainz/ui_options_acousticbrainz_tags.py
================================================
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'plugins/acousticbrainz/ui_options_acousticbrainz_tags.ui'
#
# Created by: PyQt5 UI code generator 5.15.7
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_AcousticBrainzOptionsPage(object):
def setupUi(self, AcousticBrainzOptionsPage):
AcousticBrainzOptionsPage.setObjectName("AcousticBrainzOptionsPage")
AcousticBrainzOptionsPage.setEnabled(True)
AcousticBrainzOptionsPage.resize(527, 443)
AcousticBrainzOptionsPage.setWindowTitle("")
self.verticalLayout = QtWidgets.QVBoxLayout(AcousticBrainzOptionsPage)
self.verticalLayout.setObjectName("verticalLayout")
self.acousticbrainzTags_groupBox = QtWidgets.QGroupBox(AcousticBrainzOptionsPage)
self.acousticbrainzTags_groupBox.setObjectName("acousticbrainzTags_groupBox")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.acousticbrainzTags_groupBox)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.add_simplemood = QtWidgets.QCheckBox(self.acousticbrainzTags_groupBox)
self.add_simplemood.setObjectName("add_simplemood")
self.verticalLayout_2.addWidget(self.add_simplemood)
self.simplemood_tagname_label = QtWidgets.QLabel(self.acousticbrainzTags_groupBox)
self.simplemood_tagname_label.setObjectName("simplemood_tagname_label")
self.verticalLayout_2.addWidget(self.simplemood_tagname_label)
self.simplemood_tagname = QtWidgets.QLineEdit(self.acousticbrainzTags_groupBox)
self.simplemood_tagname.setObjectName("simplemood_tagname")
self.verticalLayout_2.addWidget(self.simplemood_tagname)
self.add_simplegenre = QtWidgets.QCheckBox(self.acousticbrainzTags_groupBox)
self.add_simplegenre.setObjectName("add_simplegenre")
self.verticalLayout_2.addWidget(self.add_simplegenre)
self.simplegenre_tagname_label = QtWidgets.QLabel(self.acousticbrainzTags_groupBox)
self.simplegenre_tagname_label.setObjectName("simplegenre_tagname_label")
self.verticalLayout_2.addWidget(self.simplegenre_tagname_label)
self.simplegenre_tagname = QtWidgets.QLineEdit(self.acousticbrainzTags_groupBox)
self.simplegenre_tagname.setObjectName("simplegenre_tagname")
self.verticalLayout_2.addWidget(self.simplegenre_tagname)
self.add_keybpm = QtWidgets.QCheckBox(self.acousticbrainzTags_groupBox)
self.add_keybpm.setObjectName("add_keybpm")
self.verticalLayout_2.addWidget(self.add_keybpm)
self.add_fullhighlevel = QtWidgets.QCheckBox(self.acousticbrainzTags_groupBox)
self.add_fullhighlevel.setObjectName("add_fullhighlevel")
self.verticalLayout_2.addWidget(self.add_fullhighlevel)
self.add_sublowlevel = QtWidgets.QCheckBox(self.acousticbrainzTags_groupBox)
self.add_sublowlevel.setObjectName("add_sublowlevel")
self.verticalLayout_2.addWidget(self.add_sublowlevel)
spacerItem = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred)
self.verticalLayout_2.addItem(spacerItem)
self.sublowlevel_descr = QtWidgets.QLabel(self.acousticbrainzTags_groupBox)
self.sublowlevel_descr.setTextFormat(QtCore.Qt.RichText)
self.sublowlevel_descr.setObjectName("sublowlevel_descr")
self.verticalLayout_2.addWidget(self.sublowlevel_descr)
spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
self.verticalLayout_2.addItem(spacerItem1)
self.verticalLayout.addWidget(self.acousticbrainzTags_groupBox)
self.retranslateUi(AcousticBrainzOptionsPage)
QtCore.QMetaObject.connectSlotsByName(AcousticBrainzOptionsPage)
def retranslateUi(self, AcousticBrainzOptionsPage):
_translate = QtCore.QCoreApplication.translate
self.acousticbrainzTags_groupBox.setTitle(_translate("AcousticBrainzOptionsPage", "AcousticBrainz Tags"))
self.add_simplemood.setText(_translate("AcousticBrainzOptionsPage", "Add simple Mood tags"))
self.simplemood_tagname_label.setText(_translate("AcousticBrainzOptionsPage", "Mood tag name:"))
self.add_simplegenre.setText(_translate("AcousticBrainzOptionsPage", "Add simple Genre tags"))
self.simplegenre_tagname_label.setText(_translate("AcousticBrainzOptionsPage", "Genre tag name:"))
self.add_keybpm.setText(_translate("AcousticBrainzOptionsPage", "Add simple BPM and Key tags"))
self.add_fullhighlevel.setText(_translate("AcousticBrainzOptionsPage", "Add all highlevel AcousticBrainz tags"))
self.add_sublowlevel.setText(_translate("AcousticBrainzOptionsPage", "Add a subset of the lowlevel AcousticBrainz tags"))
self.sublowlevel_descr.setText(_translate("AcousticBrainzOptionsPage", "
"))
================================================
FILE: plugins/acousticbrainz/ui_options_acousticbrainz_tags.ui
================================================
from the AcousticBrainz database.
This plugin is deprecated, please consider using the AcousticBrainz Tags
plugin instead.
'''
PLUGIN_LICENSE = "GPL-2.0-or-later"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt"
PLUGIN_VERSION = '1.1.6'
PLUGIN_API_VERSIONS = ["2.0"] # Requires support for TKEY which is in 1.4
from json import JSONDecodeError
from picard import log
from picard.metadata import register_track_metadata_processor
from functools import partial
from picard.webservice import ratecontrol
from picard.util import load_json
ACOUSTICBRAINZ_HOST = "acousticbrainz.org"
ACOUSTICBRAINZ_PORT = 80
ratecontrol.set_minimum_delay((ACOUSTICBRAINZ_HOST, ACOUSTICBRAINZ_PORT), 1000)
class AcousticBrainz_Key:
def get_data(self, album, track_metadata, track_node, release_node):
if "musicbrainz_recordingid" not in track_metadata:
log.error("%s: Error parsing response. No MusicBrainz recording id found.",
PLUGIN_NAME)
return
recordingId = track_metadata['musicbrainz_recordingid']
if recordingId:
log.debug("%s: Add AcousticBrainz request for %s (%s)",
PLUGIN_NAME, track_metadata['title'], recordingId)
self.album_add_request(album)
path = "/%s/low-level" % recordingId
return album.tagger.webservice.get(
ACOUSTICBRAINZ_HOST,
ACOUSTICBRAINZ_PORT,
path,
partial(self.process_data, album, track_metadata),
priority=True,
important=False,
parse_response_type=None)
def process_data(self, album, track_metadata, response, reply, error):
if error:
log.error("%s: Network error retrieving acousticBrainz data for recordingId %s",
PLUGIN_NAME, track_metadata['musicbrainz_recordingid'])
self.album_remove_request(album)
return
try:
data = load_json(response)
except JSONDecodeError:
log.error("%s: Network error retrieving AcousticBrainz data for recordingId %s",
PLUGIN_NAME, track_metadata['musicbrainz_recordingid'])
self.album_remove_request(album)
return
if "tonal" in data:
if "key_key" in data["tonal"]:
key = data["tonal"]["key_key"]
if "key_scale" in data["tonal"]:
scale = data["tonal"]["key_scale"]
if scale == "minor":
key += "m"
track_metadata["key"] = key
log.debug("%s: Track '%s' is in key %s", PLUGIN_NAME, track_metadata["title"], key)
if "rhythm" in data:
if "bpm" in data["rhythm"]:
bpm = int(data["rhythm"]["bpm"] + 0.5)
track_metadata["bpm"] = bpm
log.debug("%s: Track '%s' has %s bpm", PLUGIN_NAME, track_metadata["title"], bpm)
self.album_remove_request(album)
def album_add_request(self, album):
album._requests += 1
def album_remove_request(self, album):
album._requests -= 1
if album._requests == 0:
album._finalize_loading(None)
register_track_metadata_processor(AcousticBrainz_Key().get_data)
================================================
FILE: plugins/add_to_collection/README.md
================================================
# Add to Collection
This plugin allows you to add any saved release to one of your user release [collections](https://musicbrainz.org/doc/Collections).
Download [here](https://picard.musicbrainz.org/api/v2/download?id=add_to_collection).
---
The plugin adds a settings page under the "Plugins" section under "Options..." from Picard's main menu that lets you choose
the collection you want to add the releases to

================================================
FILE: plugins/add_to_collection/__init__.py
================================================
from picard.plugins.add_to_collection.manifest import *
from picard.plugins.add_to_collection import options, post_save_processor
options.register_options()
post_save_processor.register_processor()
================================================
FILE: plugins/add_to_collection/manifest.py
================================================
PLUGIN_NAME = "Add to Collection"
PLUGIN_AUTHOR = "Dvir Yitzchaki (dvirtz@gmail.com)"
PLUGIN_DESCRIPTION = "Adds any saved release to one of your user collections"
PLUGIN_VERSION = "0.1.2"
PLUGIN_API_VERSIONS = ["2.0"]
PLUGIN_LICENSE = ("MIT",)
PLUGIN_LICENSE_URL = "https://spdx.org/licenses/MIT.html"
PLUGIN_USER_GUIDE_URL = (
"https://github.com/metabrainz/picard-plugins/blob/2.0/plugins/add_to_collection/README.md"
)
================================================
FILE: plugins/add_to_collection/options.py
================================================
from picard.collection import Collection, user_collections
from picard.ui.options import OptionsPage, register_options_page
from picard.plugins.add_to_collection import settings
from picard.plugins.add_to_collection.manifest import PLUGIN_NAME
from picard.plugins.add_to_collection.ui_add_to_collection_options import (
Ui_AddToCollectionOptions,
)
from picard.plugins.add_to_collection.override_module import override_module
class AddToCollectionOptionsPage(OptionsPage):
NAME = "add-to-collection"
TITLE = PLUGIN_NAME
PARENT = "plugins"
options = [settings.collection_id_option()]
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_AddToCollectionOptions()
self.ui.setupUi(self)
def load(self) -> None:
self.set_collection_name(settings.collection_id())
def save(self) -> None:
settings.set_collection_id(self.ui.collection_name.currentData())
def set_collection_name(self, value: str) -> None:
self.ui.collection_name.clear()
collection: Collection
for collection in sorted(user_collections.values(), key=lambda c: c.name.lower()):
self.ui.collection_name.addItem(collection.name, collection.id)
idx = self.ui.collection_name.findData(value)
if idx != -1:
self.ui.collection_name.setCurrentIndex(idx)
def register_options() -> None:
with override_module(AddToCollectionOptionsPage):
register_options_page(AddToCollectionOptionsPage)
================================================
FILE: plugins/add_to_collection/override_module.py
================================================
from contextlib import contextmanager
from typing import Generator
@contextmanager
def override_module(obj: object) -> Generator[None, None, None]:
# picard expects hooks to be defined at module level
module = obj.__module__
obj.__module__ = ".".join(module.split(".")[:-1])
yield
obj.__module__ = module
================================================
FILE: plugins/add_to_collection/post_save_processor.py
================================================
from picard import log
from picard.collection import Collection, user_collections
from picard.file import File, register_file_post_save_processor
from picard.plugins.add_to_collection import settings
from picard.plugins.add_to_collection.override_module import override_module
def post_save_processor(file: File) -> None:
collection_id = settings.collection_id()
if not collection_id:
log.error("cannot find collection ID setting")
return
collection: Collection = user_collections.get(collection_id)
if not collection:
log.error(f"cannot find collection with id {collection_id}")
return
release_id = file.metadata["musicbrainz_albumid"]
if release_id and release_id not in collection.releases:
log.debug("Adding release %r to %r", release_id, collection.name)
collection.add_releases(set([release_id]), callback=lambda: None)
def register_processor() -> None:
with override_module(post_save_processor):
register_file_post_save_processor(post_save_processor)
================================================
FILE: plugins/add_to_collection/settings.py
================================================
from picard.config import TextOption, get_config
from typing import Optional
COLLECTION_ID = "add_to_collection_id"
def collection_id_option() -> TextOption:
return TextOption(section="setting", name=COLLECTION_ID, default=None)
def collection_id() -> Optional[str]:
config = get_config()
if COLLECTION_ID in config.setting:
return config.setting[COLLECTION_ID]
return None
def set_collection_id(value: str) -> None:
config = get_config()
config.setting[COLLECTION_ID] = value
================================================
FILE: plugins/add_to_collection/ui_add_to_collection_options.py
================================================
from PyQt5 import QtCore, QtWidgets
class Ui_AddToCollectionOptions(object):
def setupUi(self, AddToCollectionOptions):
AddToCollectionOptions.setObjectName("AddToCollectionOptions")
AddToCollectionOptions.resize(472, 215)
self.verticalLayout = QtWidgets.QVBoxLayout(AddToCollectionOptions)
self.verticalLayout.setObjectName("verticalLayout")
self.collection_label = QtWidgets.QLabel(AddToCollectionOptions)
self.collection_label.setObjectName("collection_label")
self.verticalLayout.addWidget(self.collection_label)
sizePolicy = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Fixed
)
self.collection_name = QtWidgets.QComboBox(AddToCollectionOptions)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.collection_name.sizePolicy().hasHeightForWidth()
)
self.collection_name.setSizePolicy(sizePolicy)
self.collection_name.setEditable(False)
self.collection_name.setObjectName("collection_name")
self.verticalLayout.addWidget(self.collection_name)
spacerItem = QtWidgets.QSpacerItem(
20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
)
self.verticalLayout.addItem(spacerItem)
self.retranslateUi(AddToCollectionOptions)
QtCore.QMetaObject.connectSlotsByName(AddToCollectionOptions)
def retranslateUi(self, AddToCollectionOptions):
_translate = QtCore.QCoreApplication.translate
AddToCollectionOptions.setWindowTitle(_("Form"))
self.collection_label.setText(_("Collection to add releases to:"))
================================================
FILE: plugins/additional_artists_details/__init__.py
================================================
# -*- coding: utf-8 -*-
"""Additional Artists Details
"""
# Copyright (C) 2023-2024 Bob Swift (rdswift)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
# pylint: disable=line-too-long
# pylint: disable=import-error
# pylint: disable=too-many-arguments
# pylint: disable=too-many-locals
from collections import namedtuple
from functools import partial
from picard import (
config,
log,
)
from picard.album import register_album_post_removal_processor
from picard.metadata import (
register_album_metadata_processor,
register_track_metadata_processor,
)
from picard.plugin import PluginPriority
from picard.plugins.additional_artists_details.ui_options_additional_artists_details import (
Ui_AdditionalArtistsDetailsOptionsPage,
)
from picard.webservice.api_helpers import MBAPIHelper
from picard.ui.options import (
OptionsPage,
register_options_page,
)
PLUGIN_NAME = 'Additional Artists Details'
PLUGIN_AUTHOR = 'Bob Swift (rdswift)'
PLUGIN_DESCRIPTION = '''
This plugin provides specialized album and track variables with artist details for use in tagging and naming scripts. Note that this creates
additional calls to the MusicBrainz API for the artist and area information, and this will slow down processing. This will be particularly
noticable when there are many different album or track artists, such as on a [Various Artists] release. There is an option to disable track
artist processing, which can significantly increase the processing speed if you are only interested in album artist details.
Please see the user
guide on GitHub for more information.
'''
PLUGIN_VERSION = '0.4'
PLUGIN_API_VERSIONS = ['2.0', '2.1', '2.2', '2.7', '2.8', '2.11']
PLUGIN_LICENSE = 'GPL-2.0-or-later'
PLUGIN_LICENSE_URL = 'https://www.gnu.org/licenses/gpl-2.0.html'
PLUGIN_USER_GUIDE_URL = 'https://github.com/rdswift/picard-plugins/blob/2.0_RDS_Plugins/plugins/additional_artists_details/docs/README.md'
# Named tuples for code clarity
Area = namedtuple('Area', ['parent', 'name', 'country', 'type', 'type_text'])
MetadataPair = namedtuple('MetadataPair', ['artists', 'target'])
# MusicBrainz ID codes for relationship types
RELATIONSHIP_TYPE_PART_OF = 'de7cc874-8b1b-3a05-8272-f3834c968fb7'
# MusicBrainz ID codes for area types
AREA_TYPE_COUNTRY = '06dd0ae4-8c74-30bb-b43d-95dcedf961de'
AREA_TYPE_COUNTY = 'bcecec27-8bdb-3e00-8254-d948dda502fa'
AREA_TYPE_MUNICIPALITY = '17246454-5ac4-36a1-b81a-4753eb2dab20'
# Area types to exclude from the location string
EXCLUDE_AREA_TYPES = {AREA_TYPE_MUNICIPALITY, AREA_TYPE_COUNTY}
# Standard text for arguments
ALBUM_ARTISTS = 'album_artists'
ARTIST = 'artist'
ARTIST_REQUESTS = 'artist_requests'
AREA = 'area'
AREA_REQUESTS = 'area_requests'
ISO_CODES_1 = 'iso-3166-1-codes'
ISO_CODES_2 = 'iso-3166-2-codes'
OPT_AREA_DETAILS = 'aad_area_details'
OPT_PROCESS_TRACKS = 'aad_process_tracks'
TRACKS = 'tracks'
def log_helper(text, *args):
"""Logging helper to prepend the plugin name to the text.
Args:
text (str): Text to log.
args (list): List of text replacement arguments.
Returns:
list: updated text and replacement arguments.
"""
retval = ["%s: " + text, PLUGIN_NAME]
retval.extend(args)
return retval
class CustomHelper(MBAPIHelper):
"""Custom MusicBrainz API helper to retrieve artist and area information.
"""
def get_artist_by_id(self, _id, handler, inc=None, priority=False, important=False,
mblogin=False, refresh=False):
"""Get information for the specified artist MBID.
Args:
_id (str): Artist MBID to retrieve.
handler (object): Callback used to process the returned information.
inc (list, optional): List of includes to add to the API call. Defaults to None.
priority (bool, optional): Process the request at a high priority. Defaults to False.
important (bool, optional): Identify the request as important. Defaults to False.
mblogin (bool, optional): Request requires logging into MusicBrainz. Defaults to False.
refresh (bool, optional): Request triggers a refresh. Defaults to False.
Returns:
RequestTask: Requested task object.
"""
return self._get_by_id(ARTIST, _id, handler, inc, priority=priority, important=important, mblogin=mblogin, refresh=refresh)
def get_area_by_id(self, _id, handler, inc=None, priority=False, important=False, mblogin=False, refresh=False):
"""Get information for the specified area MBID.
Args:
_id (str): Area MBID to retrieve.
handler (object): Callback used to process the returned information.
inc (list, optional): List of includes to add to the API call. Defaults to None.
priority (bool, optional): Process the request at a high priority. Defaults to False.
important (bool, optional): Identify the request as important. Defaults to False.
mblogin (bool, optional): Request requires logging into MusicBrainz. Defaults to False.
refresh (bool, optional): Request triggers a refresh. Defaults to False.
Returns:
RequestTask: Requested task object.
"""
if inc is None:
inc = ['area-rels']
return self._get_by_id(AREA, _id, handler, inc, priority=priority, important=important, mblogin=mblogin, refresh=refresh)
class ArtistDetailsPlugin:
"""Plugin to retrieve artist details, including area and country information.
"""
result_cache = {
ARTIST: {},
ARTIST_REQUESTS: set(),
AREA: {},
AREA_REQUESTS: set(),
}
album_processing_count = {}
albums = {}
def _make_empty_target(self, album_id):
"""Create an empty album target node if it doesn't exist.
Args:
album_id (str): MBID of the album.
"""
if album_id not in self.albums:
self.albums[album_id] = {ALBUM_ARTISTS: set(), TRACKS: []}
def _add_target(self, album_id, artists, target_metadata):
"""Add a metadata target to update for an album.
Args:
album_id (str): MBID of the album.
artists (set): Set of artists to include.
target_metadata (Metadata): Target metadata to update.
"""
self._make_empty_target(album_id)
self.albums[album_id][TRACKS].append(MetadataPair(artists, target_metadata))
def _remove_album(self, album_id):
"""Removes an album from the metadata processing dictionary.
Args:
album_id (str): MBID of the album to remove.
"""
log.debug(*log_helper("Removing album '%s'", album_id))
self.albums.pop(album_id, None)
self.album_processing_count.pop(album_id, None)
def _album_add_request(self, album):
"""Increment the number of pending requests for an album.
Args:
album (Album): The Album object to use for the processing.
"""
if album.id not in self.album_processing_count:
self.album_processing_count[album.id] = 0
self.album_processing_count[album.id] += 1
album._requests += 1
def _album_remove_request(self, album):
"""Decrement the number of pending requests for an album.
Args:
album (Album): The Album object to use for the processing.
"""
if album.id not in self.album_processing_count:
self.album_processing_count[album.id] = 1
self.album_processing_count[album.id] -= 1
album._requests -= 1
album._finalize_loading(None) # pylint: disable=protected-access
def remove_album(self, album):
"""Remove the album from the albums processing dictionary.
Args:
album (Album): The album object to remove.
"""
self._remove_album(album.id)
def make_album_vars(self, album, album_metadata, _release_metadata):
"""Process album artists.
Args:
album (Album): The Album object to use for the processing.
album_metadata (Metadata): Metadata object for the album.
_release_metadata (dict): Dictionary of release data from MusicBrainz api.
"""
artists = set(artist.id for artist in album.get_album_artists())
self._make_empty_target(album.id)
self.albums[album.id][ALBUM_ARTISTS] = artists
if not config.setting[OPT_PROCESS_TRACKS]:
log.info(*log_helper("Track artist processing is disabled."))
self._artist_processing(artists, album, album_metadata, 'Album')
def make_track_vars(self, album, album_metadata, track_metadata, _release_metadata):
"""Process track artists.
Args:
album (Album): The Album object to use for the processing.
album_metadata (Metadata): Metadata object for the album.
track_metadata (dict): Dictionary of track data from MusicBrainz api.
_release_metadata (dict): Dictionary of release data from MusicBrainz api.
"""
artists = set()
source_type = 'track'
# Test for valid metadata node.
# The 'artist-credit' key should always be there.
# This check is to avoid a runtime error if it doesn't exist for some reason.
if config.setting[OPT_PROCESS_TRACKS]:
if 'artist-credit' in track_metadata:
for artist_credit in track_metadata['artist-credit']:
if 'artist' in artist_credit:
if 'id' in artist_credit['artist']:
artists.add(artist_credit['artist']['id'])
else:
# No 'artist' specified. Log as an error.
self._metadata_error(album.id, 'artist-credit.artist', source_type)
else:
# No valid metadata found. Log as error.
self._metadata_error(album.id, 'artist-credit', source_type)
self._artist_processing(artists, album, album_metadata, 'Track')
def _artist_processing(self, artists, album, destination_metadata, source_type):
"""Retrieves the information for each artist not already processed.
Args:
artists (set): Set of artist MBIDs to process.
album (Album): Album object to use for the processing.
destination_metadata (Metadata): Metadata object to update with the new variables.
source_type (str): Source type (album or track) for logging messages.
"""
for temp_id in artists:
if temp_id not in self.result_cache[ARTIST_REQUESTS]:
self.result_cache[ARTIST_REQUESTS].add(temp_id)
log.debug(*log_helper('Retrieving artist ID %s information from MusicBrainz.', temp_id))
self._get_artist_info(temp_id, album)
else:
log.debug(*log_helper('%s artist ID %s information available from cache.', source_type, temp_id))
self._add_target(album.id, artists, destination_metadata)
self._save_artist_metadata(album.id)
def _save_artist_metadata(self, album_id):
"""Saves the new artist details variables to the metadata targets for the specified album.
Args:
album_id (str): MBID of the album to process.
"""
if album_id in self.album_processing_count and self.album_processing_count[album_id]:
return
if album_id not in self.albums or not self.albums[album_id][TRACKS]:
log.error(*log_helper("No metadata targets found for album '%s'", album_id))
return
for item in self.albums[album_id][TRACKS]:
# Add album artists to track so they are available in the metadata
artists = self.albums[album_id][ALBUM_ARTISTS].copy().union(item.artists)
destination_metadata = item.target
for artist in artists:
if artist in self.result_cache[ARTIST]:
self._set_artist_metadata(destination_metadata, artist, self.result_cache[ARTIST][artist])
def _set_artist_metadata(self, destination_metadata, artist_id, artist_info):
"""Adds the artist information to the destination metadata.
Args:
destination_metadata (Metadata): Metadata object to update with new variables.
artist_id (str): MBID of the artist to update.
artist_info (dict): Dictionary of information for the artist.
"""
def _set_item(key, value):
destination_metadata[f"~artist_{artist_id}_{key.replace('-', '_')}"] = value
for item in artist_info.keys():
if item in {'area', 'begin-area', 'end-area'}:
country, location = self._drill_area(artist_info[item])
if country:
_set_item(item.replace('area', 'country'), country)
if location:
_set_item(item.replace('area', 'location'), location)
else:
_set_item(item, artist_info[item])
def _get_artist_info(self, artist_id, album):
"""Gets the artist information from the MusicBrainz website.
Args:
artist_id (str): MBID of the artist to retrieve.
album (Album): The Album object to use for the processing.
"""
self._album_add_request(album)
helper = CustomHelper(album.tagger.webservice)
handler = partial(
self._artist_submission_handler,
artist=artist_id,
album=album,
)
return helper.get_artist_by_id(artist_id, handler)
def _artist_submission_handler(self, document, _reply, error, artist=None, album=None):
"""Handles the response from the webservice requests for artist information.
"""
try:
if error:
log.error(*log_helper("Artist '%s' information retrieval error.", artist))
return
artist_info = {}
for item in ['type', 'gender', 'name', 'sort-name', 'disambiguation']:
if item in document and document[item]:
artist_info[item] = document[item]
if 'life-span' in document:
for item in ['begin', 'end']:
if item in document['life-span'] and document['life-span'][item]:
artist_info[item] = document['life-span'][item]
for item in ['area', 'begin-area', 'end-area']:
if item in document and document[item] and 'id' in document[item] and document[item]['id']:
area_id = document[item]['id']
artist_info[item] = area_id
if area_id not in self.result_cache[AREA_REQUESTS]:
self._get_area_info(area_id, album)
self.result_cache[ARTIST][artist] = artist_info
finally:
self._album_remove_request(album)
self._save_artist_metadata(album.id)
def _get_area_info(self, area_id, album):
"""Gets the area information from the MusicBrainz website.
Args:
area_id (str): MBID of the area to retrieve.
album (Album): The Album object to use for the processing.
"""
self.result_cache[AREA_REQUESTS].add(area_id)
self._album_add_request(album)
log.debug(*log_helper('Retrieving area ID %s from MusicBrainz.', area_id))
helper = CustomHelper(album.tagger.webservice)
handler = partial(
self._area_submission_handler,
area=area_id,
album=album,
)
return helper.get_area_by_id(area_id, handler)
def _area_submission_handler(self, document, _reply, error, area=None, album=None):
"""Handles the response from the webservice requests for area information.
"""
try:
if error:
log.error(*log_helper("Area '%s' information retrieval error.", area))
return
(_id, name, country, _type, type_text) = self._parse_area(document)
if _type == AREA_TYPE_COUNTRY and _id not in self.result_cache[AREA]:
self._area_logger(_id, f"{name} ({country})", type_text)
self.result_cache[AREA][_id] = Area('', name, country, _type, type_text)
if 'relations' in document:
for rel in document['relations']:
self._parse_area_relation(_id, rel, album, name, _type, type_text)
finally:
self._album_remove_request(album)
self._save_artist_metadata(album.id)
@staticmethod
def _area_logger(area_id, area_name, area_type):
"""Adds a log entry for the area retrieved.
Args:
area_id (str): MBID of the area added.
area_name (str): Name of the area added.
area_type (str): Type of area added.
"""
log.debug(*log_helper("Adding area: %s => %s of type '%s'", area_id, area_name, area_type))
def _parse_area_relation(self, area_id, area_relation, album, area_name, area_type, area_type_text):
"""Parse an area relation to extract the area information.
Args:
area_id (str): MBID of the area providing the relationship.
area_relation (dict): Dictionary of the area relationship.
album (Album): The Album object to use for the processing.
area_name (str): Name of the area providing the relationship.
area_type (str): MBID of the type of area providing the relationship.
area_type_text (str): Text description of the area providing the relationship.
"""
if 'type-id' not in area_relation or 'area' not in area_relation or area_relation['type-id'] != RELATIONSHIP_TYPE_PART_OF:
return
(_id, name, country, _type, type_text) = self._parse_area(area_relation['area'])
if not _id:
return
if 'direction' in area_relation and area_relation['direction'] == 'backward':
if area_id not in self.result_cache[AREA]:
self._area_logger(area_id, area_name, area_type_text)
self.result_cache[AREA][area_id] = Area(_id, area_name, '', area_type, type_text)
self.result_cache[AREA_REQUESTS].add(area_id)
if _type == AREA_TYPE_COUNTRY:
if _id not in self.result_cache[AREA]:
self._area_logger(_id, f"{name} ({country})", type_text)
self.result_cache[AREA][_id] = Area('', name, country, _type, type_text)
self.result_cache[AREA_REQUESTS].add(_id)
else:
if _id not in self.result_cache[AREA] and _id not in self.result_cache[AREA_REQUESTS]:
self._get_area_info(_id, album)
else:
self._area_logger(_id, name, type_text)
self.result_cache[AREA_REQUESTS].add(_id)
self.result_cache[AREA][_id] = Area(area_id, name, '', _type, type_text)
@staticmethod
def _parse_area(area_info):
"""Parse a dictionary of area information to return selected elements.
Args:
area_info (dict): Area information to parse.
Returns:
tuple: Selected information for the area (id, name, country code, type code, type text).
"""
if 'id' not in area_info:
return ('', '', '', '', '')
area_id = area_info['id']
area_name = area_info['name'] if 'name' in area_info else 'Unknown Name'
area_type = area_info['type-id'] if 'type-id' in area_info else ''
area_type_text = area_info['type'] if 'type' in area_info else 'Unknown Area Type'
country = ''
if area_type == AREA_TYPE_COUNTRY:
if ISO_CODES_1 in area_info and area_info[ISO_CODES_1]:
country = area_info[ISO_CODES_1][0]
elif ISO_CODES_2 in area_info and area_info[ISO_CODES_2]:
country = area_info[ISO_CODES_2][0][:2]
return (area_id, area_name, country, area_type, area_type_text)
@staticmethod
def _metadata_error(album_id, metadata_element, metadata_group):
"""Logs metadata-related errors.
Args:
album_id (str): MBID of the album being processed.
metadata_element (str): Metadata element initiating the error.
metadata_group (str): Metadata group initiating the error.
"""
log.error(*log_helper("Album '%s' missing '%s' in %s metadata.", album_id, metadata_element, metadata_group))
def _drill_area(self, area_id):
"""Drills up from the specified area to determine the two-character
country code and the full location description for the area.
Args:
area_id (str): MBID of the area to process.
Returns:
tuple: The two-character country code and full location description for the area.
"""
country = ''
location = []
i = 7 # Counter to avoid potential runaway processing
while i and area_id and not country:
i -= 1
area = self.result_cache[AREA][area_id] if area_id in self.result_cache[AREA] else Area('', '', '', '', '')
country = area.country
area_id = area.parent
if not location or config.setting[OPT_AREA_DETAILS] or area.type not in EXCLUDE_AREA_TYPES:
location.append(area.name)
return country, ', '.join(location)
class AdditionalArtistsDetailsOptionsPage(OptionsPage):
"""Options page for the Additional Artists Details plugin.
"""
NAME = "additional_artists_details"
TITLE = "Additional Artists Details"
PARENT = "plugins"
options = [
config.BoolOption('setting', OPT_PROCESS_TRACKS, False),
config.BoolOption('setting', OPT_AREA_DETAILS, False),
]
def __init__(self, parent=None):
super(AdditionalArtistsDetailsOptionsPage, self).__init__(parent)
self.ui = Ui_AdditionalArtistsDetailsOptionsPage()
self.ui.setupUi(self)
# Enable external link
self.ui.format_description.setOpenExternalLinks(True)
def load(self):
"""Load the option settings.
"""
self.ui.cb_process_tracks.setChecked(config.setting[OPT_PROCESS_TRACKS])
self.ui.cb_area_details.setChecked(config.setting[OPT_AREA_DETAILS])
def save(self):
"""Save the option settings.
"""
# self._set_settings(config.setting)
config.setting[OPT_PROCESS_TRACKS] = self.ui.cb_process_tracks.isChecked()
config.setting[OPT_AREA_DETAILS] = self.ui.cb_area_details.isChecked()
plugin = ArtistDetailsPlugin()
# Register the plugin to run at a LOW priority so that other plugins that
# modify the artist information can complete their processing and this plugin
# is working with the latest updated data.
register_album_metadata_processor(plugin.make_album_vars, priority=PluginPriority.LOW)
register_track_metadata_processor(plugin.make_track_vars, priority=PluginPriority.LOW)
register_album_post_removal_processor(plugin.remove_album)
register_options_page(AdditionalArtistsDetailsOptionsPage)
================================================
FILE: plugins/additional_artists_details/docs/README.md
================================================
# Additional Artists Details
## Overview
This plugin provides specialized album and track variables with artist details such as type, gender, begin and end dates, location (begin, end and current) and country code (begin, end and current) for use in tagging and naming scripts.
***NOTE:*** This plugin makes additional calls to the MusicBrainz website api for the information, which will slow down retrieving album information from MusicBrainz. This will be particularly noticable when there are many different album or track artists, such as on a \[Various Artists\] release. There is an option to disable track artist processing, which can significantly increase the processing speed if you are only interested in album artist details.
---
## Option Settings
The plugin adds a settings page under the "Plugins" section under "Options..." from Picard's main menu. This allows you to control how the plugin operates with respect to processing track artists and detail included in the artist location variables' content.

---
## What it Does
This plugin reads the album and track metadata provided to Picard, extracts the list of associated artists, retrieves the detailed information from the MusicBrainz website, and exposes the information in a number of additional variables for use in Picard scripts.
### Variables Created
* **\_artist_\{artist_id\}_begin** - The begin date (birth date) of the artist
* **\_artist_\{artist_id\}_begin_country** - The begin two-character country code of the artist
* **\_artist_\{artist_id\}_begin_location** - The begin location of the artist
* **\_artist_\{artist_id\}_country** - The two-character country code for the artist
* **\_artist_\{artist_id\}_disambiguation** - The disambiguation information for the artist
* **\_artist_\{artist_id\}_end** - The end date (death date) of the artist
* **\_artist_\{artist_id\}_end_country** - The end two-character country code of the artist
* **\_artist_\{artist_id\}_end_location** - The end location of the artist
* **\_artist_\{artist_id\}_gender** - The gender of the artist
* **\_artist_\{artist_id\}_name** - The name of the artist
* **\_artist_\{artist_id\}_sort_name** - The sort name of the artist
* **\_artist_\{artist_id\}_type** - The type of artist (person, group, etc.)
A variable will only be created if the information is returned from MusicBrainz. For example, if a gender has not been specified in the MusicBrainz data then the **%\_artist_\{artist_id\}_gender%** variable will not be created.
---
## Example
If you load the release **Wrecking Ball** (release [8c759d7a-2ade-4201-abc2-a2a7c1a6ad6c](https://musicbrainz.org/release/8c759d7a-2ade-4201-abc2-a2a7c1a6ad6c)) by Sarah Blackwood (artist [af7e5ea9-bd58-4346-8f78-d672e9f297f7](https://musicbrainz.org/artist/af7e5ea9-bd58-4346-8f78-d672e9f297f7)), Jenni Pleau (artist [07fa21a9-c253-4ed0-b711-d63f7965b723](https://musicbrainz.org/artist/07fa21a9-c253-4ed0-b711-d63f7965b723)) & Emily Bones (artist [541d331c-f041-4895-b8f2-7db9e27dc5ab](https://musicbrainz.org/artist/541d331c-f041-4895-b8f2-7db9e27dc5ab)), the following variables will be created:
Sarah Blackwood (primary artist):
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_begin** = "1980-10-18"
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_begin_country** = "CA"
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_begin_location** = "Burlington, Ontario, Canada"
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_country** = "CA"
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_disambiguation** = "rockabilly, + "Somebody That I Used to Know""
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_gender** = "Female"
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_location** = "Canada"
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_name** = "Sarah Blackwood"
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_sort_name** = "Blackwood, Sarah"
* **\_artist_af7e5ea9_bd58_4346_8f78_d672e9f297f7_type** = "Person"
Jenny Pleau (additional artist)
* **\_artist_07fa21a9_c253_4ed0_b711_d63f7965b723_country** = "CA"
* **\_artist_07fa21a9_c253_4ed0_b711_d63f7965b723_gender** = "Female"
* **\_artist_07fa21a9_c253_4ed0_b711_d63f7965b723_location** = "Kitchener, Ontario, Canada"
* **\_artist_07fa21a9_c253_4ed0_b711_d63f7965b723_name** = "Jenni Pleau"
* **\_artist_07fa21a9_c253_4ed0_b711_d63f7965b723_sort_name** = "Pleau, Jenni"
* **\_artist_07fa21a9_c253_4ed0_b711_d63f7965b723_type** = "Person"
Emily Bones (additional artist)
* **\_artist_541d331c_f041_4895_b8f2_7db9e27dc5ab_gender** = "Female"
* **\_artist_541d331c_f041_4895_b8f2_7db9e27dc5ab_name** = "Emily Bones"
* **\_artist_541d331c_f041_4895_b8f2_7db9e27dc5ab_sort_name** = "Bones, Emily"
* **\_artist_541d331c_f041_4895_b8f2_7db9e27dc5ab_type** = "Person"
Note that variables will only be created for information that exists for the artist's record.
This could be used to set a **%country%** tag to the country code of the (primary) album artist with a tagging script like:
```taggerscript
$set(_tmp,_artist_$getmulti(%musicbrainz_albumartistid%,0)_)
$set(country,$if2($get(%_tmp%country),$get(%_tmp%begin_country),$get(%_tmp%end_country),xx))
```
================================================
FILE: plugins/additional_artists_details/options_additional_artists_details.ui
================================================
Please see the user guide on GitHub for more information.
'''
PLUGIN_VERSION = '1.0'
PLUGIN_API_VERSIONS = ['2.0', '2.1', '2.2', '2.7', '2.9', '2.10', '2.11']
PLUGIN_LICENSE = 'GPL-2.0-or-later'
PLUGIN_LICENSE_URL = 'https://www.gnu.org/licenses/gpl-2.0.html'
PLUGIN_USER_GUIDE_URL = 'https://github.com/rdswift/picard-plugins/blob/2.0_RDS_Plugins/plugins/additional_artists_variables/docs/README.md'
from operator import itemgetter
from picard import config, log
from picard.metadata import (register_album_metadata_processor,
register_track_metadata_processor)
from picard.plugin import PluginPriority
ID_ALIASES_ARTIST_NAME = '894afba6-2816-3c24-8072-eadb66bd04bc'
ID_ALIASES_LEGAL_NAME = 'd4dcd0c0-b341-3612-a332-c0ce797b25cf'
def process_artists(album_id, source_metadata, destination_metadata, source_type):
# Test for valid metadata node.
# The 'artist-credit' key should always be there.
# This check is to avoid a runtime error if it doesn't exist for some reason.
if 'artist-credit' in source_metadata:
# Initialize variables to default values
sort_pri_artist = ''
sort_pri_artist_cred = ''
std_artist = ''
cred_artist = ''
sort_artist = ''
cred_sort_artist = ''
legal_artist = ''
additional_std_artist = ''
additional_cred_artist = ''
additional_sort_artist = ''
additional_cred_sort_artist = ''
additional_legal_artist = ''
std_artist_list = []
cred_artist_list = []
sort_artist_list = []
cred_sort_artist_list = []
legal_artist_list = []
artist_count = 0
artist_ids = []
artist_types = []
artist_join_phrases = []
for artist_credit in source_metadata['artist-credit']:
# Initialize temporary variables for each loop.
temp_std_name = ''
temp_sort_name = ''
temp_cred_name = ''
temp_cred_sort_name = ''
temp_legal_name = ''
temp_legal_sort_name = ''
temp_phrase = ''
temp_id = ''
temp_type = ''
# Check if there is a 'joinphrase' specified.
if 'joinphrase' in artist_credit:
temp_phrase = artist_credit['joinphrase']
else:
metadata_error(album_id, 'artist-credit.joinphrase', source_type)
# Check if there is a 'name' specified.
if 'name' in artist_credit:
temp_cred_name = artist_credit['name']
else:
metadata_error(album_id, 'artist-credit.name', source_type)
# Check if there is an 'artist' specified.
if 'artist' in artist_credit:
if 'id' in artist_credit['artist']:
temp_id = artist_credit['artist']['id']
else:
metadata_error(album_id, 'artist-credit.artist.id', source_type)
if 'name' in artist_credit['artist']:
temp_std_name = artist_credit['artist']['name']
else:
metadata_error(album_id, 'artist-credit.artist.name', source_type)
if 'sort-name' in artist_credit['artist']:
temp_sort_name = artist_credit['artist']['sort-name']
temp_cred_sort_name = temp_sort_name
else:
metadata_error(album_id, 'artist-credit.artist.sort-name', source_type)
if 'type' in artist_credit['artist']:
temp_type = artist_credit['artist']['type']
else:
metadata_error(album_id, 'artist-credit.artist.type', source_type)
if 'aliases' in artist_credit['artist']:
for item in artist_credit['artist']['aliases']:
if 'type-id' in item and item['type-id'] == ID_ALIASES_LEGAL_NAME:
if 'ended' in item and not item['ended']:
if 'name' in item:
temp_legal_name = item['name']
if 'sort-name' in item:
temp_legal_sort_name = item['sort-name']
if 'type-id' in item and item['type-id'] == ID_ALIASES_ARTIST_NAME:
if 'name' in item and 'sort-name' in item and str(item['name']).lower() == temp_cred_name.lower():
temp_cred_sort_name = item['sort-name']
tag_list = []
if config.setting['max_genres']:
for tag_type in ['user-genres', 'genres', 'user-tags', 'tags']:
if tag_type in artist_credit['artist']:
for item in sorted(sorted(artist_credit['artist'][tag_type], key=itemgetter('name')), key=itemgetter('count'), reverse=True):
if item['count'] > 0:
tag_list.append(item['name'])
tag_list = tag_list[:config.setting['max_genres']]
else:
# No 'artist' specified. Log as an error.
metadata_error(album_id, 'artist-credit.artist', source_type)
std_artist += temp_std_name + temp_phrase
cred_artist += temp_cred_name + temp_phrase
sort_artist += temp_sort_name + temp_phrase
cred_sort_artist += temp_cred_sort_name + temp_phrase
artist_types.append(temp_type if temp_type else 'unknown',)
artist_join_phrases.append(temp_phrase if temp_phrase else '\u200B',)
if temp_legal_name:
legal_artist += temp_legal_name + temp_phrase
legal_artist_list.append(temp_legal_name,)
else:
# Use standardized name for combined string if legal name not available
legal_artist += temp_std_name + temp_phrase
# Use 'n/a' for list if legal name not available
legal_artist_list.append('n/a',)
if temp_std_name:
std_artist_list.append(temp_std_name,)
if temp_sort_name:
sort_artist_list.append(temp_sort_name,)
if temp_cred_name:
cred_artist_list.append(temp_cred_name,)
if temp_cred_sort_name:
cred_sort_artist_list.append(temp_cred_sort_name,)
if temp_id:
artist_ids.append(temp_id,)
if artist_count < 1:
if temp_id:
destination_metadata['~artists_{0}_primary_id'.format(source_type,)] = temp_id
destination_metadata['~artists_{0}_primary_std'.format(source_type,)] = temp_std_name
destination_metadata['~artists_{0}_primary_cred'.format(source_type,)] = temp_cred_name
destination_metadata['~artists_{0}_primary_sort'.format(source_type,)] = temp_sort_name
destination_metadata['~artists_{0}_primary_cred_sort'.format(source_type,)] = temp_cred_sort_name
destination_metadata['~artists_{0}_primary_legal'.format(source_type,)] = temp_legal_name
destination_metadata['~artists_{0}_primary_sort_legal'.format(source_type,)] = temp_legal_sort_name
sort_pri_artist += temp_sort_name + temp_phrase
sort_pri_artist_cred += temp_cred_sort_name + temp_phrase
if tag_list and source_type == 'album':
destination_metadata['~artists_{0}_primary_tags'.format(source_type,)] = tag_list
else:
sort_pri_artist += temp_std_name + temp_phrase
additional_std_artist += temp_std_name + temp_phrase
additional_cred_artist += temp_cred_name + temp_phrase
additional_sort_artist += temp_sort_name + temp_phrase
if temp_legal_name:
additional_legal_artist += temp_legal_name + temp_phrase
else:
additional_legal_artist += temp_std_name + temp_phrase
if temp_cred_sort_name:
additional_cred_sort_artist += temp_cred_sort_name + temp_phrase
sort_pri_artist_cred += temp_cred_sort_name + temp_phrase
else:
additional_cred_sort_artist += temp_sort_name + temp_phrase
sort_pri_artist_cred += temp_sort_name + temp_phrase
artist_count += 1
else:
# No valid metadata found. Log as error.
metadata_error(album_id, 'artist-credit', source_type)
additional_std_artist_list = std_artist_list[1:]
additional_cred_artist_list = cred_artist_list[1:]
additional_sort_artist_list = sort_artist_list[1:]
additional_cred_sort_artist_list = cred_sort_artist_list[1:]
additional_legal_artist_list = legal_artist_list[1:]
additional_artist_ids = artist_ids[1:]
if additional_artist_ids:
destination_metadata['~artists_{0}_additional_id'.format(source_type,)] = additional_artist_ids
if additional_std_artist:
destination_metadata['~artists_{0}_additional_std'.format(source_type,)] = additional_std_artist
if additional_cred_artist:
destination_metadata['~artists_{0}_additional_cred'.format(source_type,)] = additional_cred_artist
if additional_sort_artist:
destination_metadata['~artists_{0}_additional_sort'.format(source_type,)] = additional_sort_artist
if additional_cred_sort_artist:
destination_metadata['~artists_{0}_additional_cred_sort'.format(source_type,)] = additional_cred_sort_artist
if additional_legal_artist:
destination_metadata['~artists_{0}_additional_legal'.format(source_type,)] = additional_legal_artist
if additional_std_artist_list:
destination_metadata['~artists_{0}_additional_std_multi'.format(source_type,)] = additional_std_artist_list
if additional_cred_artist_list:
destination_metadata['~artists_{0}_additional_cred_multi'.format(source_type,)] = additional_cred_artist_list
if additional_sort_artist_list:
destination_metadata['~artists_{0}_additional_sort_multi'.format(source_type,)] = additional_sort_artist_list
if additional_cred_sort_artist_list:
destination_metadata['~artists_{0}_additional_cred_sort_multi'.format(source_type,)] = additional_cred_sort_artist_list
if additional_legal_artist_list:
destination_metadata['~artists_{0}_additional_legal_multi'.format(source_type,)] = additional_legal_artist_list
if std_artist:
destination_metadata['~artists_{0}_all_std'.format(source_type,)] = std_artist
if cred_artist:
destination_metadata['~artists_{0}_all_cred'.format(source_type,)] = cred_artist
if cred_sort_artist:
destination_metadata['~artists_{0}_all_cred_sort'.format(source_type,)] = cred_sort_artist
if sort_artist:
destination_metadata['~artists_{0}_all_sort'.format(source_type,)] = sort_artist
if legal_artist:
destination_metadata['~artists_{0}_all_legal'.format(source_type,)] = legal_artist
if std_artist_list:
destination_metadata['~artists_{0}_all_std_multi'.format(source_type,)] = std_artist_list
if cred_artist_list:
destination_metadata['~artists_{0}_all_cred_multi'.format(source_type,)] = cred_artist_list
if sort_artist_list:
destination_metadata['~artists_{0}_all_sort_multi'.format(source_type,)] = sort_artist_list
if cred_sort_artist_list:
destination_metadata['~artists_{0}_all_cred_sort_multi'.format(source_type,)] = cred_sort_artist_list
if legal_artist_list:
destination_metadata['~artists_{0}_all_legal_multi'.format(source_type,)] = legal_artist_list
if sort_pri_artist:
destination_metadata['~artists_{0}_all_sort_primary'.format(source_type,)] = sort_pri_artist
if artist_types:
destination_metadata['~artists_{0}_all_types'.format(source_type,)] = artist_types
if artist_join_phrases:
destination_metadata['~artists_{0}_all_join_phrases'.format(source_type,)] = artist_join_phrases
if artist_count:
destination_metadata['~artists_{0}_all_count'.format(source_type,)] = artist_count
def make_album_vars(album, album_metadata, release_metadata):
album_id = release_metadata['id'] if release_metadata else 'No Album ID'
process_artists(album_id, release_metadata, album_metadata, 'album')
def make_track_vars(album, album_metadata, track_metadata, release_metadata):
album_id = release_metadata['id'] if release_metadata else 'No Album ID'
process_artists(album_id, track_metadata, album_metadata, 'track')
def metadata_error(album_id, metadata_element, metadata_group):
log.error("{0}: {1!r}: Missing '{2}' in {3} metadata.".format(
PLUGIN_NAME, album_id, metadata_element, metadata_group,))
# Register the plugin to run at a LOW priority so that other plugins that
# modify the artist information can complete their processing and this plugin
# is working with the latest updated data.
register_album_metadata_processor(make_album_vars, priority=PluginPriority.LOW)
register_track_metadata_processor(make_track_vars, priority=PluginPriority.LOW)
================================================
FILE: plugins/addrelease/addrelease.py
================================================
# -*- coding: utf-8 -*-
PLUGIN_NAME = "Add Cluster As Release"
PLUGIN_AUTHOR = 'Frederik "Freso" S. Olesen, Lukáš Lalinský, Philip Jägenstedt'
PLUGIN_DESCRIPTION = "Adds a plugin context menu option to clusters and single\
files to help you quickly add them as releases or standalone recordings to\
the MusicBrainz database via the website by pre-populating artists,\
track names and times."
PLUGIN_VERSION = "0.7.3"
PLUGIN_API_VERSIONS = ["2.0"]
from picard import config, log
from picard.cluster import Cluster
from picard.const import MUSICBRAINZ_SERVERS
from picard.file import File
from picard.util import webbrowser2
from picard.ui.itemviews import BaseAction, register_cluster_action, register_file_action
import os
import tempfile
HTML_HEAD = """
The information is provided in the following variables:
PLEASE NOTE: Once the plugin is installed, it automatically makes these
variables available to File Naming Scripts and other scripts in Picard.
Like other variables, you must mention them in a script for them to affect
the file name or other data.
This plugin is no longer being maintained.
Consider switching to the
[Additional Artists Variables plugin](https://github.com/rdswift/picard-plugins/tree/2.0/plugins/additional_artists_variables),
which fills this
role, and also includes additional variables. That other plugin uses different
names for the album artist names provided here, so you if you switch plugins, you
will need to update your scripts with the different names.
Version 0.6.1 of this plugin functions identically to Version 0.6. Only this
description (and the version number) has changed.
'''
PLUGIN_VERSION = "0.6.1"
PLUGIN_API_VERSIONS = ["2.0"]
PLUGIN_LICENSE = "GPL-2.0-or-later"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"
from picard import config, log
from picard.metadata import register_album_metadata_processor
from picard.plugin import PluginPriority
def add_artist_std_name(album, album_metadata, release_metadata):
album_id = release_metadata['id']
# Test for valid metadata node for the release
if 'artist-credit' in release_metadata:
# Initialize variables to default values
cred_artist = ""
std_artist = ""
sort_artist = ""
artist_count = 0
# The 'artist-credit' key should always be there.
# This check is to avoid a runtime error if it doesn't exist for some reason.
for artist_credit in release_metadata['artist-credit']:
# Initialize temporary variables for each loop.
temp_std_name = ""
temp_cred_name = ""
temp_sort_name = ""
temp_phrase = ""
# Check if there is a 'joinphrase' specified.
if 'joinphrase' in artist_credit:
temp_phrase = artist_credit['joinphrase']
else:
metadata_error(album_id, 'artist-credit.joinphrase')
# Check if there is a 'name' specified.
if 'name' in artist_credit:
temp_cred_name = artist_credit['name']
else:
metadata_error(album_id, 'artist-credit.name')
# Check if there is an 'artist' specified.
if 'artist' in artist_credit:
# Check if there is a 'name' specified.
if 'name' in artist_credit['artist']:
temp_std_name = artist_credit['artist']['name']
else:
metadata_error(album_id, 'artist-credit.artist.name')
if 'sort-name' in artist_credit['artist']:
temp_sort_name = artist_credit['artist']['sort-name']
else:
metadata_error(album_id, 'artist-credit.artist.sort-name')
else:
metadata_error(album_id, 'artist-credit.artist')
std_artist += temp_std_name + temp_phrase
cred_artist += temp_cred_name + temp_phrase
sort_artist += temp_sort_name + temp_phrase
if artist_count < 1:
album_metadata["~aaeStdPrimaryAlbumArtist"] = temp_std_name
album_metadata["~aaeCredPrimaryAlbumArtist"] = temp_cred_name
album_metadata["~aaeSortPrimaryAlbumArtist"] = temp_sort_name
artist_count += 1
else:
metadata_error(album_id, 'artist-credit')
if std_artist:
album_metadata["~aaeStdAlbumArtists"] = std_artist
if cred_artist:
album_metadata["~aaeCredAlbumArtists"] = cred_artist
if sort_artist:
album_metadata["~aaeSortAlbumArtists"] = sort_artist
if artist_count:
album_metadata["~aaeAlbumArtistCount"] = artist_count
return None
def metadata_error(album_id, metadata_element):
log.error("%s: %r: Missing '%s' in release metadata.",
PLUGIN_NAME, album_id, metadata_element)
# Register the plugin to run at a LOW priority so that other plugins that
# modify the artist information can complete their processing and this plugin
# is working with the latest updated data.
register_album_metadata_processor(add_artist_std_name, priority=PluginPriority.LOW)
================================================
FILE: plugins/amazon/amazon.py
================================================
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
# Copyright (C) 2007 Oliver Charles
# Copyright (C) 2007-2011, 2019, 2021 Philipp Wolfer
# Copyright (C) 2007, 2010, 2011 Lukáš Lalinský
# Copyright (C) 2011 Michael Wiencek
# Copyright (C) 2011-2012 Wieland Hoffmann
# Copyright (C) 2013-2016 Laurent Monin
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
PLUGIN_NAME = 'Amazon cover art'
PLUGIN_AUTHOR = 'MusicBrainz Picard developers'
PLUGIN_DESCRIPTION = 'Use cover art from Amazon.'
PLUGIN_VERSION = "1.1"
PLUGIN_API_VERSIONS = ["2.2", "2.3", "2.4", "2.5", "2.6", "2.7"]
PLUGIN_LICENSE = "GPL-2.0-or-later"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"
from picard import log
from picard.coverart.image import CoverArtImage
from picard.coverart.providers import (
CoverArtProvider,
register_cover_art_provider,
)
from picard.util import parse_amazon_url
# amazon image file names are unique on all servers and constructed like
#
If you want to be more selective as to what is included in host tags, then disable this option and use the tag mapping section to get the data from the hidden variables. If you want to add arrangers as composers, do so in the tag mapping section also.
(Note that Picard does not normally pick up all arrangers, but that the plugin will do so, provided the "Works and parts" section is run.)
"Name album as 'Composer Last Name(s): Album Name'" will add the composer(s) last name(s) before the album name, if they are listed as album artists. If there is more than one composer, they will be listed in the descending order of the length of their music on the release. MusicBrainz style is to exclude the composer name unless it is actually part of the album name, but it can be useful to add it for library organisation. The default is checked.
"Do not write 'lyricist' tag if no vocal performers". Hopefully self-evident. This applies to both the Picard 'lyricist' tag and the related internal plugin hidden variables '\_cwp\_lyricists' etc.
Note that the plugin will search for lyricists at all work levels (bottom up), but will stop after finding the first one (unless that was just a translator).
"Do not include attributes in an instrument type" (previously just referred to the attribute 'solo'). MusicBrainz permits the use of "solo", "guest" and "additional" as instrument attributes although, for classical music, its use should be fairly rare - usually only if explicitly stated as a "solo" on the the sleevenotes. Classical Extras provides the option to exclude these attributes (the default), but you may wish to enable them for certain releases or non-Classical / cross-over releases.
"Annotations": The chosen text will be used to annotate the artist type within the host tag (see table above for host tags), but only if "Modify host tags" is selected.
Please note that the use of the word "master" is the MusicBrainz term and is not intended to be gender-specific. Users can specify whatever text they please.
5. "Lyrics". **Please note that this section operates on the underlying input file tags, not the Picard-generated tags (MusicBrainz does not have lyrics)**
Sometimes "lyrics" tags can contain album notes (repeated for every track in an album) as well as track notes and lyrics. This section will filter out the common text for a release and place it in a different tag from the text which is unique to each track.
"Split lyrics tag": enables this section.
"Incoming lyrics tag": The name of the lyrics file tag in the input file (normally just 'lyrics').
"Tag for album notes": The name of the tag where common text should be placed.
"Tag for track notes": The name of the tag where notes/lyrics unique to a track should be placed.
Note that if the 'output' tags are not specified, then internal 'hidden' variables will still be available for use in the tag-mapping section (called album\_notes and track\_notes).
## Work and parts tab
There six coloured sections as shown in the screen print below:

1. "Include all work levels" should be selected otherwise this section will not run. This is the default.
"Include collection relationships" (selected by default) will include parent works where the relationship has the attribute 'part of collection'. See [Discussion](https://community.metabrainz.org/t/levels-in-the-structure-of-works/293047/109) for the background to this. Note that only "work" entity types will be included, not "series" entities. If this option is changed, it will not take effect on releases already loaded in Picard - you will need to quit and restart. PLEASE BE CONSISTENT and do not use different options on albums with the same works, otherwise you may not get what you want.
"Use cache (if available)" prevents excessive look-ups of the MB database. Every look-up of a work needs to be performed separately (hopefully the MB database might make this easier some day). Network usage constraints by MB means that each look-up takes a minimum of 1 second. Once a release has been looked-up, the works are retained in cache, significantly reducing the time required if, say, the options are changed and the data refreshed. However, if the user edits the works in the MB database then the cache will need to be turned off temporarily for the refresh to find the new/changed works. Also some types of work (e.g. arrangements) will require a full look-up if options have been changed. **Do not leave this option turned off** as it will make the plugin slower and may cause problems. This option will always be set on when Picard is started, regardless of how it was left when it was last closed.
2. "Tagging style". This section determines how the hierarchy of works will be sourced.
* **Works source**: There are 3 options for determing the principal source of the works metadata
- "Use only metadata from title text". The plugin will attempt to extract the hierarchy of works from the track title by looking for repetitions and patterns, using the work structure in MusicBrainz as a guide. If the title does not contain all the work names in the hierarchy then obviously this will limit what can be provided.
- "Use only metadata from canonical works". The names from the hierarchy in the MB database will be used. Assuming the work is correctly entered in MB, this should provide all the data. However the text may differ from the track titles and will be the same for all recordings. It may also be in the language of the composer whereas the titles will probably be in the language of the release. (This language issue can also be addressed by using aliases - see below).
- "Use canonical work metadata enhanced with title text". This supplements the canonical data with text from the titles **where it is significantly different**. The supplementary title data will be in curly brackets. This is clearly the most complete metadata style of the three but may lead to long descriptions. It is particularly useful for providing translations - see image below for an example (using the Muso library manager). In this example, title text that is similar to that in the canonical text has been eliminated to make the text shorter - the mannr of doing this is controlled by settings on the Advanced tab.

* **Source of canonical work text**. Where either of the second two options above are chosen, there is a further choice to be made:
- "Full MusicBrainz work hierarchy". The names of each level of work are used to populate the relevant tags. E.g. if ""Concert Fantasy for Piano and Orchestra, op. 56: I. Quasi Rondo" (level 0) is part of "Concert Fantasia, op. 56" (level 1) then that is how they will appear, since there is no repetition of text between parent and child. So, while accurate, this option might sometimes be rather verbose.
- "Consistent with lowest level work description". The names of the level 0 work are used to populate the relevant tags. So, in the above example, "Concert Fantasy for Piano and Orchestra, op. 56" will be shown as the work and "I. Quasi Rondo" will be shown as the movement. Sometimes this may look better, but not always, **particularly if the level 0 work name does not contain all the parent work detail**. If the full structure is not implicit in the level 0 name then a warning will be logged and written to the "warning" tag.
**Version 2.0 update**: the second option is needed less often now as there is a more sophisticated matching algorithm for the canonical work names. Text may be eliminated at places other than the start, and synonyms may be used to achieve greater matching. These options are set on the Advanced tab. Setting "Removal of common text between parent and child works" to 2 (the default) and including "Fantasia" as a synonym of "Fantasy" yields the following result:
Work: "Concert Fantasia, op. 56", Movement: "for Piano and Orchestra, … : I. Quasi Rondo"
This still repeats "for Piano and Orchestra" for each movement as this text is in level 0, not level 1 (where it only appears as disambiguation). Arguably the best way to fix this is to have consistent work names in MB. (Of course, this specific example may have been fixed in MB by now, but the principle still holds). The strategy below has been updated to reflect this
**Strategy for setting style:** *It is suggested that you start with "extended/enhanced" style and the "Full MusicBrainz work hierarchy" as the source (this is the default) and tweak the advanced settings if necessary. If this does not give acceptable results, try switching to "Consistent with lowest level work description". If the "enhanced" details in curly brackets (from the track title) give odd results then, again, try tweaking the advanced settings (see later section) or switch the style to "canonical works" only. Any remaining oddities are probably in the MusicBrainz data, which may require editing.*
* **"Attempt to get works and movement info from title if there are no work relationships? (Requires title in form "work: movement")"**.
Pretty much what it says. It may be that the track is classical, but no work relationships exist in MusicBrainz. In this case, Classical Extras will attempt to infer work and movement from the title, provided they are separated by ": " (which is the Classical Style Guideline).
In this case, the other tag style settings are irrelevant. Note that if there is no related work, then there will not be a composer metadata item in MusicBrainz. However, you can use tag mapping to set this or (better) use Muso (or and XML reference file) to determine classical composers (see Genres section).
3. "Aliases"
"Replace work names by aliases" will use **primary** aliases for the chosen locale instead of standard MusicBrainz work names. To choose the locale, use the drop-down under "translate artist names" in the main Picard Options-->Metadata page. Note that this option is not saved as a file tag since, if different choices are made for different releases, different work names may be stored and therefore cannot be grouped together in your player/library manager. The sub-options then allow either the replacement of all work names, where a primary alias exists, just the replacement of work names which are in non-Latin script, or only replace those which are flagged with user "Folksonomy" tags. The tag text needs to be included in the text box, in which case flagged works will be 'aliased' as well as non-Latin script works, if the second sub-option is chosen. Note that the tags may either be anyone's tags ("Look in all tags") or the user's own tags. If selecting "Look in user's own tags only" you **must** be logged in to your MusicBrainz user account (in the Picard Options->General page), otherwise repeated dialogue boxes may be generated and you may need to force restart Picard.
4. "Tags to create" sets the names of the tags that will be created from the sources described above. All these tags will be blanked before filling as specified. Tags specified against more than one source will have later sources appended in the sequence specified, separated by separators as specified.
* **Work tags**:
- "Tags for Work - for software with 2-level capability". Some software (notably Muso) can display a 2-level work hierarchy as well as the work-movement hierarchy. This tag can be use to store the 2-level work name (a double colon :: is used to separate the levels within the tag).
- "Tags for Work - for software with 1-level capability". Software which can display a movement and work (but no higher levels) could use any tags specified here. Note that if there are multiple work levels, the intermediate levels will not be tagged. Users wanting all the information should use the tags from the previous option (but it may cause some breaks in the display if levels change) - alternatively the missing work levels can be included in a movement tag (see below).
- "Tags for top-level (canonical) work". This is the top-level work held in MB. This can be useful for cataloguing and searching (if the library software is capable).
* **Movement/Part tags**:
(a) "Tags for (computed) movement number". This is not necessarily the embedded movt/part number, but is the sequence number of the movement within its parent work **on the current release**.
(For these purposes, the "parent work" is the highest level work of which the track/movement is a a part but which is not a collection)
(b) "Tags for (computed) total number of movements". This will be the total number of movements in the parent work as numbered above.
(c) "Tags for Movement - excluding embedded movt/part numbers". As below, but without the movement part/number prefix (if applicable)
(d) "Tags for Movement - including embedded movt/part numbers". This tag(s) will contain the full lowest-level part name extracted from the lowest-level work name, according to the chosen tagging style.
For options (c) and (d), the tags can either be filled "for use with multi-level work tags" or "for use with 1-level work tags (intermediate works will prefix movement)" - or different tags for each column. The latter option will include any intermediate work levels which are missing from a single-level work tag. Use different tag names for these, from the multi-level version, otherwise both versions will be appended, creating a multi-valued tag (a warning will be given).
The default tags for (a), (b), and (c) are movementnumber, movementtotal and movement respectively - these are the standard Picard tags for these items.
Note that if a tag is included in (a) and either of (c) or (d), the movement number will be prepended at the beginning of the tag, followed by the selected separator. For more complex combinations, use the Tag Mapping tab (e.g. movementnumber + \ of + movementtotal).
If you wish to use items (a) and (b) in the tag-mapping section without populating the Picard standard tags, then use the hidden variables movt_num and movt_tot.
For more details, see the hidden variables section.
**Strategy for setting tags:** *It is suggested that initially you just use the multi-level work tag and related movement tags, even if your software only has a single-level work capability. This may result in work names being repeated in work headings, but may look better than the alternative of having work names repeated in movement names. This is the default.*
*If this does not look good you can then compare it with the alternative approach and change as required for specific releases. If your software does not have any "work" capability, then you can still get the full work details by, for example, specifying "title" as both a work and a movement tag.*
5. "Partial recordings, arrangements and medleys" gives various options where recordings are not just simply of a named complete work. These only apply if one of the two "canonical work" styles is in operation (i.e. not if "Use only metadata from title text" is selected).
* **Partial recordings**:
If this option is selected, partial recordings will be treated as a sub-part of the whole recording and will have the related text (in the adjacent box) included in its name. Note that this text is placed at the start of the canonical name, but the latter will probably be stripped from the sub-part as it duplicates the recording work name; any title text (for "extended" style) will be appended to the whole. Note that, if "Consistent with lowest level work description" is chosen in section 2, the text may be treated as a "prefix" similar to those in the "Advanced" tab. If this eliminates other similar prefixes and has unwanted effects, then either change the desired text slightly (e.g. surround with brackets) or use the "Full MusicBrainz work hierarchy" option in section 2. Note that similar text between the partial work and its 'parent' will be removed which will frequently result in no text other than the specified 'partial text', unless extended metadata is used resulting in appended text in {} - this behaviour can be controlled by disabling the setting "Allow blank part names for arrangements and part recordings..." on the advanced tab.
* **Arrangements**:
If this option is selected, works which are arrangements of other works will have the latter treated in the same manner as "parent" works, except that the arrangement work name will be prefixed by the text provided. Note that similar text between the arranged work and its parent will be removed unless this results in no text, in which case a stricter comparison (as for the derivation of 'part' names from works) will be used.
**Important note:** *If the Partial or Arrangement options are changed (i.e. selected/deselected) then quit and restart Picard as the work structure is fundamentally different. If the related text (only) is changed then the release can simply be refreshed.*
* **Medleys**
These can occur in two ways in MusicBrainz: (a) the recording is described as a "medley of" a number of works and (b) the track is described as (more than one) "medley including a recording of" a work. See [Homecoming](https://musicbrainz.org/release/393913a2-7fde-4ed5-8be6-ca5c2c0ccf0d) for examples of both (tracks 8, 9 and 11). In the first case, the specified text will be included in brackets after the work name, whereas in the second case, the track will be treated as a recording of multiple works and the specified text will appear in the **parent** work name.
6. "SongKong-compatible tag usage".
"Use work tags on file (no look up on MB) if Use Cache selected": This will enable the existing work tags on the file to be used in preference to looking up on MusicBrainz, if those tags are SongKong-compatible (which should be the case if SongKong has been used or if the SongKong tags have been previously written by this plugin). If present, this can speed up processing considerably, but obviously any new data on MusicBrainz will be missed. For the option to operate, "Use cache" also needs to be selected. Although faster, many of the subtleties of a full look-up will be missed - for example, parent works which are arrangements will not be highlighted as such, some arrangers or composers of original works may be omitted and some medley information may be missed. Other information, such as composed-dates will also be missing. **In general, therefore, the use of this option will result in poorer metadata than allowing the full database look-up to run. It is not recommended unless you have already tagged your files with SongKong and speed is more important than quality.**
"Write SongKong-compatible work tags" does what it says. These can then be used by the previous option, if the release is subsequently reloaded into Picard, to speed things up (assuming the reload was not to pick up new work data). The same caveats as those above apply.
**Note that, as from version 2.0.2, there is no significant speed difference between (basic) SongKong and Picard with Classical Extras, so this feature is only ever useful if the album has already been tagged with SongKong.**
The default for both these options is unchecked.
Note that Picard and SongKong use the tag musicbrainz\_workid to mean different things. If Picard has overwritten the SongKong tag (not a problem if this plugin is used) then a warning will be given and the works will be looked up on MusicBrainz. Also note that once a release is loaded, subsequent refreshes will use the cache (if option is ticked) in preference to the file tags.
**Note for iTunes users:** *iTunes and Picard do not work well together. iTunes can display work and movement for m4a(mp4) files, but Picard does not write the movement tag. To work round this, write the movement to the "subtitle" tag assuming that is not otherwise used, and use a simple Mp3tag action to convert it to MOVEMENTNAME before importing to iTunes. If you are writing to a FLAC file which will subsequently be converted to m4a then different tag names may be required; e.g. using dBpoweramp, write the movement to "movement name". In both cases use "work" for the work. To store the top\_work, use "grouping" if writing directly to m4a, but "style" if writing to FLAC followed by dBpoweramp conversion. You can put multiple tags into the boxes described above so that your options are multi-purpose. N.B. if work tags are specified and the work has at least one level (i.e. at least work: movement), then the tag "show work movement" will be set to 1. This is used by iTunes to trigger the hierarchical display and should work both directly with m4a files and indirectly via files which are subsequently converted.*
## Genres etc. tab
This section is dependent on both the artists and workparts sections. If either of those sections are not run then this section will not operate correctly. At the very top of the tab is a checkbox "Use Muso reference database...". For [Muso](http://klarita.net/muso.html) users, selecting this enables you to use reference data for genres, composers and periods which have been entered in Muso's "Options->Classical Music" section. Regardless as to whether this is selected, there are then three main coloured sections, each with a number of subsections. The details in each section differ depending on whether the "Muso" option is selected. The screen print below shows the options assuming it is not selected (differences occurring when "Muso" is selected are discussed later):

1. "Genres". Two separate tags may be used to store genre information, a main genre tage (usually just "genre") and a sub-genre tag. These need to be specified at the top of the section. If either is left blank then the related processing will not run.
* **Source of genres**
Any or all of four sources may be selected. In each case, any values found are treated as "candidate genres" - they will only be applied to the specified genre and sub-genre tags in accordance with the criteria in the "allowed genres" section, if any (see below).
(a) "Existing file tag". The contents of the existing file tag (as specified above - main genre tag only) will be included as candidate genres. Note that, if this tag name is not "genre", then the contents of the tag "genre" will be included as well.
(b) "Folksonomy work tags". This will use the folksonomy tags for **works** (including parent works) as a possible source of genres. To use the folksonomy tags for **releases/tracks**, select the main Picard option in Options->Metadata->"Use folksonomy tags as genre". Again (unlike vanilla Picard) these are candidate genres, and will only be published if they match the allowed genres.
(c) "Work-type". The work-type attribute of works or parent works will be used as a candidate genre.
(d) "Infer from artist metadata". This option was on the artist tab in version 0.9.1 and prior. Owing to the additional genre processing now available, the operation of this option is slightly restricted compared to the earlier versions. It attempts to create candidate genres based on information in the artist-related tags. Values provided are:
Orchestral, Concerto, Choral, Opera, Duet,Trio, Quartet, Chamber music, Aria ('classical values') and Vocal, Song, Instrumental ('generic values'). If the track is a recorded work and the track artist is the composer (i.e. MusicBrainz 'classical style'), the candidate genre values will also include "Classical". The 'classical values' will only be included as candidate genres if the track is deemed to be 'classical' by some part of the genre processing section.
* **Allowed genres**
A check-box (ticked by default) enables this section. If it is unchecked, then no genre filtering is applied - all 'candidate genres' will be written to the genre tab.
Four boxes are provided for lists of genres which are "allowed" to appear in the specified tags. Each list should be comma-separated (and no commas in any genre name). Candidate genres matching those in a "main genre" box will be added to the specified main genre tag. Similarly for sub-genres. If a candidate genre matches a 'classical genre' (in one of the top two boxes), then the track will be deemed to be "Classical" (see next part for more details).
You may also enter a genre name to be used if no matching main genre is found (otherwise the tag will be blank).
* **"Classical" genre**
Normally (i.e. by default) a work will only be deemed to be 'classical' if it is inferred from the MusicBrainz style (see "source of genres") or if a candidate genre matches a "Classical" genre or sub-genre list. However, you may select that all tracks are 'classical' regardless. There is also an option to exclude the word "Classical" from any genre tag, but still treat the work as classical. If a work is deemed to be classical, a tag may be written with a specified value as set out in the last two boxes of this section. For example, to be consistent with SonKong/Jaikoz, you could set "is\_classical" to "1".
2. "Instruments and keys".
* **Instruments**
Specify the tag name you wish instrument names to appear in. Instruments will be sourced from performer relationships. Instrument names may either be the standard MusicBrainz names or the "credited as" names in the performer relationship, or both. Vocal types are treated similarly to instruments. (Note that, in v0.9.1 and prior, instruments were written to the same tag as inferred genres. If you wish to continue this, then you may use the same tag name here as for the genre tag.)
* **Keys**
Specify the tag name in which you wish the key signatures of works to appear. Keys will be obtained from all work levels (assuming these have been looked up): for example, Dvořák's Largo From the New World will be shown as D♭ major, C# minor (the main keys of the movement) and E minor (the home key of the overall work).
"Include key(s) in work names" gives the option to include the key signature for a work in brackets after the name of the work in the metadata. Keys will be added in the appropriate levels: e.g. Dvořák's New World Symphony will get (E minor) at the work level, but only movements with different keys will be annotated viz. "II. Largo (D-flat major, C-Sharp minor)". The default sub-option is to only add these details if the key signature is missing from the work title (other sub-options are to never or to always include the information.
3. "Periods and dates".
* **Work dates**
Specify the tag name to hold work dates. Work dates will be given as a "year" value only, e.g. "1808" or a range: "1808-1810". The sources of these dates is specified in the next part. If the movement has a composed date(s), this will be used, otherwise the the dates from the parent work will be used (if available).
"Source of work dates". Select which sources to use - from composed, published and premiered, then decide whether to use them in preferential order (e.g. if "composed date" exists, then the others will not be used) or to show them all.
"Include workdate in work name ..." operates analogously to "Include key(s) in work names" described above. (Work dates will be used in preference order, i.e. composed - published - premiered, with only the first available date being shown).
* **Periods**
This section will use work dates, where available, to determine the "classical period" to which it belongs, by means of a "period map" (Muso users can also use composer dates - see below).
Specify the tag name to hold the period data. The period map should then be entered in the format "Period name, Start\_year, End\_year; Period name2, Start\_year, End\_year;" etc. Periods may overlap. Do not use commas or semi-colons within period names. Start and end years must be integers.
## Genres etc. tab - Muso-specific processing
Users of [Muso](http://klarita.net/muso.html) have additional capabilities, illustrated in the following screen, which appear when the option "Use Muso reference database ..." is selected at the top of the tab.

For these options to work, the path/name of the Muso reference database needs to be specified on the advanced tab. The default path is "C:\\Users\\Public\\Music\\muso\\database" and the default filename is "Reference.xml". The additional options are as follows.
1. "Use Muso classical genres". If this is selected, the box for classical main genres is eliminated and the genre list from Muso's "Tools->Options->Classical Music->Classical Music Genres" is used instead.
2. "Use Muso composer list to determine if classical". If the composer name is in Muso's list "Tools->Options->Classical Music->Composer Roster", then the work will be deemed to be classical. If this option is selected, a further option appears to "Treat arrangers as for composers" - if selected then arrangers will also be looked up in the roster.
3. "Use Muso composer dates (if no work date) to determine period". The birth date + 20 -> death dates of Muso's composer roster will be used to assign periods if no work date is available. If this option is selected, a further option appears to "Treat arrangers as for composers" - if selected then arrangers' working lives will also be used to determine periods.
(This might be replaced / supplemented by MusicBrainz in the future, but would involve another 1-second lookup per composer).
4. "Use Muso map". Replace the period map with the one in Muso at "Tools->Options->Classical Music->Classical Music Periods"
Note that non-Muso users may also use this functionality, if they wish, by manually creating a reference xml file with the relevant tags, e.g.:
Artist type Host tag Hidden variable writer composer writers lyricist lyricist lyricists revised by arranger revisors translator lyricist translators arranger arranger arrangers reconstructed by arranger reconstructors orchestrator arranger orchestrators instrument arranger arranger arrangers (with instrument type in brackets) vocal arranger arranger arrangers (with voice type in brackets) chorus master conductor chorusmasters concertmaster performer (with annotation as a sub-key) leaders
The options screen provides five tabs for users to control the tags produced:
1. Artists: Options as to whether artist tags will contain standard MB names, aliases or as-credited names.
Ability to include and annotate names for specialist roles (chorus master, arranger, lyricist etc.).
Ability to read lyrics tags on the file which has been loaded and assign them to track and album levels if required.
(Note: Picard will not normally process incoming file tags).
2. Works and parts: The plugin will build a hierarchy of works and parts (e.g. Work -> Part -> Movement or
Opera -> Act -> Number) based on the works in MusicBrainz's database. These can then be displayed in tags in a variety
of ways according to user preferences. Furthermore partial recordings, medleys, arrangements and collections of works
are all handled according to user choices. There is a processing overhead for this at present because MusicBrainz limits
look-ups to one per second.
3. Genres etc.: Options are available to customise the source and display of information relating to genres,
instruments, keys, work dates and periods. Additional capabilities are provided for users of Muso (or others who
provide the relevant XML files) to use pre-existing databases of classical genres, classical composers and classical
periods.
4. Tag mapping: in some ways, this is a simple substitute for some of Picard's scripting capability. The main advantage
is that the plugin will remember what tag mapping you use for each release (or even track).
5. Advanced: Various options to control the detailed processing of the above.
All user options can be saved on a per-album (or even per-track) basis so that tweaks can be used to deal with
inconsistencies in the MusicBrainz data (e.g. include English titles from the track listing where the MusicBrainz works
are in the composer's language and/or script).
Also existing file tags can be processed (not possible in native Picard).
See the readme file
on GitHub here for full details.
"""
########################
# DEVELOPERS NOTES: ####
########################
# This plugin contains 3 classes:
#
# I. ("EXTRA ARTISTS") Create sorted fields for all performers. Creates a number of variables with alternative values
# for "artists" and "artist".
# Creates an ensemble variable for all ensemble-type performers.
# Also creates matching sort fields for artist and artists.
# Additionally create tags for artist types which are not normally created in Picard - particularly for classical music
# (notably instrument arrangers).
#
# II. ("PART LEVELS" [aka Work Parts]) Create tags for the hierarchy of works which contain a given track recording
# - particularly for classical music'
# Variables provided for each work level, with implied part names
# Mixed metadata provided including work and title elements
#
# III. ("OPTIONS") Allows the user to set various options including what tags will be written
# (otherwise the classes above will just write outputs to "hidden variables")
#
# The main control routine is at the end of the module
PLUGIN_VERSION = '2.0.14'
PLUGIN_API_VERSIONS = ["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7"]
PLUGIN_LICENSE = "GPL-2.0"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"
from picard.ui.options import register_options_page, OptionsPage
from picard.plugins.classical_extras.ui_options_classical_extras import Ui_ClassicalExtrasOptionsPage
import picard.plugins.classical_extras.suffixtree
from picard import config, log
from picard.config import ConfigSection, BoolOption, IntOption, TextOption
from picard.util import LockableObject, uniqify
# note that in 2.0 picard.webservice changed to picard.util.xml
from picard.util.xml import XmlNode
from picard.util import translate_from_sortname
from picard.metadata import register_track_metadata_processor, Metadata
from functools import partial
from datetime import datetime
import collections
import re
import unicodedata
import json
import copy
import os
from PyQt5.QtCore import QXmlStreamReader
from picard.const import USER_DIR
import operator
import ast
import picard.plugins.classical_extras.const
##########################
# MODULE-WIDE COMPONENTS #
##########################
# CONSTANTS
# N.B. Constants with long definitions are set in const.py
DATE_SEP = '-'
# COMMONLY USED REGEX
ROMAN_NUMERALS = r'\b((?=[MDCLXVI])(M{0,4}(CM|CD|D?)?C{0,3}(XC|XL|L?)?X{0,3}(IX|IV|V?)?I{0,3}))(?:\.|\-|:|;|,|\s|$)'
ROMAN_NUMERALS_AT_START = r'^\W*' + ROMAN_NUMERALS
RE_ROMANS = re.compile(ROMAN_NUMERALS, re.IGNORECASE)
RE_ROMANS_AT_START = re.compile(ROMAN_NUMERALS_AT_START, re.IGNORECASE)
# KEYS
RE_NOTES = r'(\b[ABCDEFG])'
RE_ACCENTS = r'(\-sharp(?:\s+|\b)|\-flat(?:\s+|\b)|\ssharp(?:\s+|\b)|\sflat(?:\s+|\b)|\u266F(?:\s+|\b)|\u266D(?:\s+|\b)|(?:[:,.]?\s+|$|\-))'
RE_SCALES = r'(major|minor)?(?:\b|$)'
RE_KEYS = re.compile(
RE_NOTES + RE_ACCENTS + RE_SCALES,
re.UNICODE | re.IGNORECASE)
# LOGGING
# If logging occurs before any album is loaded, the startup log file will
# be written
log_files = collections.defaultdict(dict)
# entries are release-ids: to keep track of which log files are open
release_status = collections.defaultdict(dict)
# release_status[release_id]['works'] = True indicates that we are still processing works for release_id
# & similarly for 'artists'
# release_status[release_id]['start'] holds start time of release processing
# release_status[release_id]['name'] holds the album name
# release_status[release_id]['lookups'] holds number of lookups for this release
# release_status[release_id]['file_objects'] holds a cumulative list of file objects (tagger seems a bit unreliable)
# release_status[release_id]['file_found'] = False indicates that "No file
# with matching trackid" has (yet) been found
def write_log(release_id, log_type, message, *args):
"""
Custom logging function - if log_info is set, all messages will be written to a custom file in a 'Classical_Extras'
subdirectory in the same directory as the main Picard log. A different file is used for each album,
to aid in debugging - the log file is release_id.log. Any startup messages (i.e. before a release has been loaded)
are written to session.log. Summary information for each release is also written to session.log even if log_info
is not set.
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param log_type: 'error', 'warning', 'debug' or 'info'
:param message: string, e.g. 'error message for workid: %s'
:param args: arguments for parameters in string, e.g. if workId then str(workId) will replace %s in the above
:return:
"""
options = config.setting
if not isinstance(message, str):
msg = repr(message)
else:
msg = message
if args:
msg = msg % args
if options["log_info"] or log_type == "basic":
# if log_info is True, all log messages will be written to the custom log, regardless of other log_... settings
# basic session log will always be written (summary of releases and
# processing times)
filename = release_id + ".log"
log_dir = os.path.join(USER_DIR, "Classical_Extras")
if not os.path.exists(log_dir):
os.makedirs(log_dir)
if release_id not in log_files:
try:
if release_id == 'session':
log_file = open(
os.path.join(
log_dir,
filename),
'w',
encoding='utf8',
buffering=1)
# buffering=1 so that session log (low volume) is up to
# date even if not closed
else:
log_file = open(
os.path.join(
log_dir,
filename),
'w',
encoding='utf8') # , buffering=1)
# default buffering for speed, buffering = 1 for currency
log_files[release_id] = log_file
log_file.write(
PLUGIN_NAME +
' Version:' +
PLUGIN_VERSION +
'\n')
if release_id == 'session':
log_file.write('session' + '\n')
else:
log_file.write('Release id: ' + release_id + '\n')
if release_id in release_status and 'name' in release_status[release_id]:
log_file.write(
'Album name: ' + release_status[release_id]['name'] + '\n')
except IOError:
log.error('Unable to open file %s for writing log', filename)
return
else:
log_file = log_files[release_id]
try:
log_file.write(log_type[0].upper() + ': ')
log_file.write(str(datetime.now()) + ' : ')
log_file.write(msg)
log_file.write("\n")
except IOError:
log.error('Unable to write to log file %s', filename)
return
# Only debug, warning and error messages will be written to the main
# Picard log, if those options have been set
if log_type != 'info' and log_type != 'basic': # i.e. non-custom log items
message2 = PLUGIN_NAME + ': ' + message
else:
message2 = message
if log_type == 'debug' and options["log_debug"]:
if release_id in release_status and 'debug' in release_status[release_id]:
add_list_uniquely(release_status[release_id]['debug'], msg)
else:
release_status[release_id]['debug'] = [msg]
log.debug(message2, *args)
if log_type == 'warning' and options["log_warning"]:
if release_id in release_status and 'warnings' in release_status[release_id]:
add_list_uniquely(release_status[release_id]['warnings'], msg)
else:
release_status[release_id]['warnings'] = [msg]
if args:
log.warning(message2, *args)
else:
log.warning(message2)
if log_type == 'error' and options["log_error"]:
if release_id in release_status and 'errors' in release_status[release_id]:
add_list_uniquely(release_status[release_id]['errors'], msg)
else:
release_status[release_id]['errors'] = [msg]
if args:
log.error(message2, *args)
else:
log.error(message2)
def close_log(release_id, caller):
# close the custom log file if we are done
if release_id == 'session': # shouldn't happen but, just in case, don't close the session log
return
if caller in ['works', 'artists']:
release_status[release_id][caller] = False
if (caller == 'works' and release_status[release_id]['artists']) or \
(caller == 'artists' and release_status[release_id]['works']):
# log.error('exiting close_log. only %s done', caller) # debug line
return
duration = 'N/A'
lookups = 'N/A'
artists_time = 0
works_time = 0
lookup_time = 0
album_process_time = 0
if release_id in release_status:
duration = datetime.now() - release_status[release_id]['start']
lookups = release_status[release_id]['lookups']
done_lookups = release_status[release_id]['done-lookups']
lookup_time = done_lookups - release_status[release_id]['start']
album_process_time = duration - lookup_time
artists_time = release_status[release_id]['artists-done'] - \
release_status[release_id]['start']
works_time = release_status[release_id]['works-done'] - \
release_status[release_id]['start']
del release_status[release_id]['start']
del release_status[release_id]['lookups']
del release_status[release_id]['done-lookups']
del release_status[release_id]['artists-done']
del release_status[release_id]['works-done']
if release_id in log_files:
write_log(
release_id,
'info',
'Duration = %s. Number of lookups = %s.',
duration,
lookups)
write_log(release_id, 'info', 'Closing log file for %s', release_id)
log_files[release_id].close()
del log_files[release_id]
if 'session' in log_files and release_id in release_status:
write_log(
'session',
'basic',
'\n Completed processing release id %s. Details below:-',
release_id)
if 'name' in release_status[release_id]:
write_log('session', 'basic', 'Album name %s',
release_status[release_id]['name'])
if 'errors' in release_status[release_id]:
write_log(
'session',
'basic',
'-------------------- Errors --------------------')
for error in release_status[release_id]['errors']:
write_log('session', 'basic', error)
del release_status[release_id]['errors']
if 'warnings' in release_status[release_id]:
write_log(
'session',
'basic',
'-------------------- Warnings --------------------')
for warning in release_status[release_id]['warnings']:
write_log('session', 'basic', warning)
del release_status[release_id]['warnings']
if 'debug' in release_status[release_id]:
write_log(
'session',
'basic',
'-------------------- Debug log --------------------')
for debug in release_status[release_id]['debug']:
write_log('session', 'basic', debug)
del release_status[release_id]['debug']
write_log(
'session',
'basic',
'Duration = %s. Artists time = %s. Works time = %s. Of which: Lookup time = %s. '
'Album-process time = %s. Number of lookups = %s.',
duration,
artists_time,
works_time,
lookup_time,
album_process_time,
lookups)
if release_id in release_status:
del release_status[release_id]
# FILE READING AND OBJECT PARSING
_node_name_re = re.compile('[^a-zA-Z0-9]')
def _node_name(n):
return _node_name_re.sub('_', str(n))
def _read_xml(stream):
document = XmlNode()
current_node = document
path = []
while not stream.atEnd():
stream.readNext()
if stream.isStartElement():
node = XmlNode()
attrs = stream.attributes()
for i in range(attrs.count()):
attr = attrs.at(i)
node.attribs[_node_name(attr.name())] = str(attr.value())
current_node.append_child(_node_name(stream.name()), node)
path.append(current_node)
current_node = node
elif stream.isEndElement():
current_node = path.pop()
elif stream.isCharacters():
current_node.text += str(stream.text())
return document
def get_preferred_artist_language(config):
locales = config.setting["artist_locales"]
if locales:
artist_locale = locales[0]
else:
artist_locale = config.setting["artist_locale"]
if artist_locale:
return artist_locale.split('_')[0]
else:
return ''
def parse_data(release_id, obj, response_list, *match):
"""
This function takes any XmlNode object, or list thereof, or a JSON object
and extracts a list of all objects exactly matching the hierarchy listed in match.
match should contain list of each node in hierarchical sequence, with no gaps in the sequence
of nodes, to lowest level required.
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param obj: an XmlNode or JSON object, list or dictionary containing nodes
:param response_list: working memory for recursive calls
:param match: list of items to search for in node (see detailed notes below)
:return: a list of matching items (always a list, even if only one item)
Insert attribs.attribname:attribvalue in the list to select only branches where attribname
is attribvalue. (Omit the attribs prefix if the obj is JSON)
Insert childname.text:childtext in the list to select only branches where
a sibling with childname has text childtext.
(Note: childname can be a dot-list if the text is more than one level down - e.g. child1.child2
# TODO - Check this works fully )
"""
if '!log' in response_list:
DEBUG = True
INFO = True
else:
DEBUG = False
INFO = False
# Normally logging options are off as these can be VERY wordy
# They can be turned on by using !log in the call
# XmlNode instances are not iterable, so need to convert to dict
if isinstance(obj, XmlNode):
obj = obj.__dict__
if DEBUG or INFO:
write_log(release_id, 'debug', 'Parsing data - looking for %s', match)
if INFO:
write_log(release_id, 'info', 'Looking in object: %s', obj)
if isinstance(obj, list):
objlen = len(obj)
for i, item in enumerate(obj):
if isinstance(item, XmlNode):
item = item.__dict__
if INFO:
write_log(
release_id,
'info',
'Getting response for list item no.%s of %s - object is: %s',
i + 1,
objlen,
item)
parse_data(release_id, item, response_list, *match)
if INFO:
write_log(
release_id,
'info',
'response_list for list item no.%s of %s is %s',
i + 1,
objlen,
response_list)
return response_list
elif isinstance(obj, dict):
if match[0] in obj:
if len(match) == 1:
response = obj[match[0]]
if response is not None: # To prevent adding NoneTypes to list
response_list.append(response)
if INFO:
write_log(
release_id,
'info',
'response_list (last match item): %s',
response_list)
else:
match_list = list(match)
match_list.pop(0)
parse_data(release_id, obj[match[0]],
response_list, *match_list)
if INFO:
write_log(
release_id,
'info',
'response_list (passing up): %s',
response_list)
return response_list
elif ':' in match[0]:
test = match[0].split(':')
match2 = test[0].split('.')
test_data = parse_data(release_id, obj, [], *match2)
if INFO:
write_log(
release_id,
'info',
'Value comparison - looking in %s for value %s',
test_data,
test[1])
if len(test) > 1:
# latter is because Booleans are stored as such, not as
# strings, in JSON
if (test[1] in test_data) or (
(test[1] == 'True') in test_data):
if len(match) == 1:
response = obj
if response is not None:
response_list.append(response)
else:
match_list = list(match)
match_list.pop(0)
parse_data(release_id, obj, response_list, *match_list)
else:
parse_data(release_id, obj, response_list, *match2)
if INFO:
write_log(
release_id,
'info',
'response_list (from value look-up): %s',
response_list)
return response_list
else:
if 'children' in obj:
parse_data(release_id, obj['children'], response_list, *match)
if INFO:
write_log(
release_id,
'info',
'response_list (from children): %s',
response_list)
return response_list
else:
if INFO:
write_log(
release_id,
'info',
'response_list (obj is not a list or dict): %s',
response_list)
return response_list
def create_dict_from_ref_list(options, release_id, ref_list, keys, tags):
ref_dict_list = []
for refs in ref_list:
for ref in refs:
parsed_refs = [
parse_data(
release_id,
ref,
[],
t,
'text') for t in tags]
ref_dict_list.append(dict(zip(keys, parsed_refs)))
return ref_dict_list
def get_references_from_file(release_id, path, filename):
"""
Lookup Muso Reference.xml or similar
:param release_id: name of log file
:param path: Reference file path
:param filename: Reference file name
:return:
"""
options = config.setting
composer_dict_list = []
period_dict_list = []
genre_dict_list = []
xml_file = None
try:
xml_file = open(os.path.join(path, filename), encoding="utf8")
reply = xml_file.read()
xml_file.close()
document = _read_xml(QXmlStreamReader(reply))
# Composers
composer_list = parse_data(
release_id, document, [], 'ReferenceDB', 'Composer')
keys = ['name', 'sort', 'birth', 'death', 'country', 'core']
tags = ['Name', 'Sort', 'Birth', 'Death', 'CountryCode', 'Core']
composer_dict_list = create_dict_from_ref_list(
options, release_id, composer_list, keys, tags)
# Periods
period_list = parse_data(
release_id,
document,
[],
'ReferenceDB',
'ClassicalPeriod')
keys = ['name', 'start', 'end']
tags = ['Name', 'Start_x0020_Date', 'End_x0020_Date']
period_dict_list = create_dict_from_ref_list(
options, release_id, period_list, keys, tags)
# Genres
genre_list = parse_data(
release_id,
document,
[],
'ReferenceDB',
'ClassicalGenre')
keys = ['name']
tags = ['Name']
genre_dict_list = create_dict_from_ref_list(
options, release_id, genre_list, keys, tags)
except (IOError, FileNotFoundError, UnicodeDecodeError):
if options['cwp_muso_genres'] or options['cwp_muso_classical'] or options['cwp_muso_dates'] or options['cwp_muso_periods']:
write_log(
release_id,
'error',
'File %s does not exist or is corrupted',
os.path.join(
path,
filename))
finally:
if xml_file:
xml_file.close()
return {
'composers': composer_dict_list,
'periods': period_dict_list,
'genres': genre_dict_list}
# OPTIONS
def get_preserved_tags():
preserved = config.setting["preserved_tags"]
if isinstance(preserved, str):
preserved = [x.strip() for x in preserved.split(',')]
return preserved
def get_options(release_id, album, track):
"""
Get the saved options from a release and use them according to flags set on the "advanced" tab
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album: current release
:param track: current track
:return: None (result is passed via tm)
A common function for both Artist and Workparts, so that the first class to process a track will execute
this function so that the results are available to both (via a track metadata item)
"""
release_status[release_id]['done'] = False
set_options = collections.defaultdict(dict)
main_sections = ['artists', 'workparts']
all_sections = ['artists', 'tag', 'workparts', 'genres']
parent_sections = {
'artists': 'artists',
'tag': 'artists',
'workparts': 'workparts',
'genres': 'workparts'}
# The above needs to be done for legacy reasons - there are only two tags which store options - artists and workparts
# This dates from when there were only two sections
# To split these now will create compatibility issues
override = {
'artists': 'cea_override',
'tag': 'ce_tagmap_override',
'workparts': 'cwp_override',
'genres': 'ce_genres_override'}
sect_text = {'artists': 'Artists', 'workparts': 'Works'}
prefix = {'artists': 'cea', 'workparts': 'cwp'}
if album.tagger.config.setting['ce_options_overwrite'] and all(
album.tagger.config.setting[override[sect]] for sect in main_sections):
set_options[track] = album.tagger.config.setting # mutable
else:
set_options[track] = option_settings(
album.tagger.config.setting) # make a copy
if set_options[track]["log_info"]:
write_log(
release_id,
'info',
'Default (i.e. per UI) options for track %s are %r',
track,
set_options[track])
# As we use some of the main Picard options and may over-write them, save them here
# set_options[track]['translate_artist_names'] = config.setting['translate_artist_names']
# set_options[track]['standardize_artists'] = config.setting['standardize_artists']
# (not sure this is needed - TODO reconsider)
options = set_options[track]
tm = track.metadata
new_metadata = None
orig_metadata = None
# Only look up files if needed
file_options = {}
music_file = ''
music_file_found = None
release_status[release_id]['file_found'] = False
start = datetime.now()
if options["log_info"]:
write_log(release_id, 'info', 'Clock start at %s', start)
trackno = tm['tracknumber']
discno = tm['discnumber']
album_filenames = album.tagger.get_files_from_objects([album])
if options["log_info"]:
write_log(
release_id,
'info',
'No. of album files found = %s',
len(album_filenames))
# Note that sometimes Picard fails to get all the file objects, even if they are there (network issues)
# so we will cache whatever we can get!
if release_id in release_status and 'file_objects' in release_status[release_id]:
add_list_uniquely(
release_status[release_id]['file_objects'],
album_filenames)
else:
release_status[release_id]['file_objects'] = album_filenames
if options["log_info"]:
write_log(release_id, 'info', 'No. of album files cached = %s',
len(release_status[release_id]['file_objects']))
track_file = None
for album_file in release_status[release_id]['file_objects']:
if options["log_info"]:
write_log(release_id,
'info',
'Track file = %s, tracknumber = %s, discnumber = %s. Metadata trackno = %s, discno = %s',
album_file.filename,
str(album_file.tracknumber),
str(album_file.discnumber),
trackno,
discno)
if str(
album_file.tracknumber) == trackno and str(
album_file.discnumber) == discno:
if options["log_info"]:
write_log(
release_id,
'info',
'Track file found = %r',
album_file.filename)
track_file = album_file.filename
break
# Note: It would have been nice to do a rough check beforehand of total tracks,
# but ~totalalbumtracks is not yet populated
if not track_file:
album_fullnames = [
x.filename for x in release_status[release_id]['file_objects']]
if options["log_info"]:
write_log(
release_id,
'info',
'Album files found = %r',
album_fullnames)
for music_file in album_fullnames:
new_metadata = album.tagger.files[music_file].metadata
if 'musicbrainz_trackid' in new_metadata and 'musicbrainz_trackid' in tm:
if new_metadata['musicbrainz_trackid'] == tm['musicbrainz_trackid']:
track_file = music_file
break
# Nothing found...
if new_metadata and 'musicbrainz_trackid' not in new_metadata:
if options['log_warning']:
write_log(
release_id,
'warning',
'No trackid in file %s',
music_file)
if 'musicbrainz_trackid' not in tm:
if options['log_warning']:
write_log(
release_id,
'warning',
'No trackid in track %s',
track)
#
# Note that, on initial load, new_metadata == orig_metadata; but, after refresh, new_metadata will have
# the same track metadata as tm (plus the file metadata as per orig_metadata), so a trackid match
# is then possible for files that do not have musicbrainz_trackid in orig_metadata. That is why
# new_metadata is used in the above test, rather than orig_metadata, but orig_metadata is then used below
# to get the saved options.
#
# Find the tag with the options:-
if track_file:
orig_metadata = album.tagger.files[track_file].orig_metadata
music_file_found = track_file
if options['log_info']:
write_log(
release_id,
'info',
'orig_metadata for file %s is',
music_file)
write_log(release_id, 'info', orig_metadata)
for child_section in all_sections:
section = parent_sections[child_section]
if options[override[child_section]]:
if options[prefix[section] + '_options_tag'] + ':' + \
section + '_options' in orig_metadata:
file_options[section] = interpret(
orig_metadata[options[prefix[section] + '_options_tag'] + ':' + section + '_options'])
elif options[prefix[section] + '_options_tag'] in orig_metadata:
options_tag_contents = orig_metadata[options[prefix[section] + '_options_tag']]
if isinstance(options_tag_contents, list):
options_tag_contents = options_tag_contents[0]
combined_options = ''.join(options_tag_contents.split(
'(workparts_options)')).split('(artists_options)')
for i, _ in enumerate(combined_options):
combined_options[i] = interpret(
combined_options[i].lstrip('; '))
if isinstance(
combined_options[i],
dict) and 'Classical Extras' in combined_options[i]:
if sect_text[section] + \
' options' in combined_options[i]['Classical Extras']:
file_options[section] = combined_options[i]
else:
for om in orig_metadata:
if ':' + section + '_options' in om:
file_options[section] = interpret(
orig_metadata[om])
if section not in file_options or not file_options[section]:
if options['log_error']:
write_log(
release_id,
'error',
'Saved ' +
section +
' options cannot be read for file %s. Using current settings',
music_file)
append_tag(
release_id,
tm,
'~' +
prefix[section] +
'_error',
'1. Saved ' +
section +
' options cannot be read. Using current settings')
release_status[release_id]['file_found'] = True
end = datetime.now()
if options['log_info']:
write_log(release_id, 'info', 'Clock end at %s', end)
write_log(release_id, 'info', 'Duration = %s', end - start)
if not release_status[release_id]['file_found']:
if options['log_warning']:
write_log(
release_id,
'warning',
"No file with matching trackid for track %s. IF THERE SHOULD BE ONE, TRY 'REFRESH'",
track)
append_tag(
release_id,
tm,
"002_important_warning",
"No file with matching trackid - IF THERE SHOULD BE ONE, TRY 'REFRESH' - "
"(unable to process any saved options, lyrics or 'keep' tags)")
# Nothing else is done with this info as yet - ideally we need to refresh and re-run
# for all releases where, say, release_status[release_id]['file_prob']
# == True TODO?
else:
if options['log_info']:
write_log(
release_id,
'info',
'Found music file: %r',
music_file_found)
for section in all_sections:
if options[override[section]]:
parent_section = parent_sections[section]
if parent_section in file_options and file_options[parent_section]:
try:
options_dict = file_options[parent_section]['Classical Extras'][sect_text[parent_section] + ' options']
except TypeError as err:
if options['log_error']:
write_log(
release_id,
'error',
'Error: %s. Saved ' +
section +
' options cannot be read for file %s. Using current settings',
err,
music_file)
append_tag(
release_id,
tm,
'~' +
prefix[parent_section] +
'_error',
'1. Saved ' +
parent_section +
' options cannot be read. Using current settings')
break
for opt in options_dict:
if isinstance(
options_dict[opt],
dict) and options[override['tag']]: # for tag line options
# **NB tag mapping lines are the only entries of type dict**
opt_list = []
for opt_item in options_dict[opt]:
opt_list.append(
{opt + '_' + opt_item: options_dict[opt][opt_item]})
else:
opt_list = [{opt: options_dict[opt]}]
for opt_dict in opt_list:
for opt_det in opt_dict:
opt_value = opt_dict[opt_det]
addn = []
if section == 'artists':
addn = plugin_options('picard')
if section == 'tag':
addn = plugin_options('tag_detail')
for ea_opt in plugin_options(section) + addn:
displayed_option = options[ea_opt['option']]
if ea_opt['name'] == opt_det:
if 'value' in ea_opt:
if ea_opt['value'] == opt_value:
options[ea_opt['option']] = True
else:
options[ea_opt['option']
] = False
else:
options[ea_opt['option']
] = opt_value
if options[ea_opt['option']
] != displayed_option:
if options['log_debug'] or options['log_info']:
write_log(
release_id,
'info',
'Options overridden for option %s = %s',
ea_opt['option'],
opt_value)
opt_text = str(opt_value)
append_tag(
release_id, tm, '003_information:options_overridden', str(
ea_opt['name']) + ' = ' + opt_text)
if orig_metadata:
keep_list = options['cea_keep'].split(",")
if options['cea_split_lyrics'] and options['cea_lyrics_tag']:
keep_list.append(options['cea_lyrics_tag'])
if options['cwp_genres_use_file']:
if 'genre' in orig_metadata:
append_tag(
release_id,
tm,
'~cwp_candidate_genres',
orig_metadata['genre'])
if options['cwp_genre_tag'] and options['cwp_genre_tag'] in orig_metadata:
keep_list.append(options['cwp_genre_tag'])
really_keep_list = get_preserved_tags()[:]
really_keep_list.append(
options['cwp_options_tag'] +
':workparts_options')
really_keep_list.append(
options['cea_options_tag'] +
':artists_options')
for tagx in keep_list:
tag = tagx.strip()
really_keep_list.append(tag)
if tag in orig_metadata:
append_tag(release_id, tm, tag, orig_metadata[tag])
if options['cea_clear_tags']:
delete_list = []
for tag_item in orig_metadata:
if tag_item not in really_keep_list and tag_item[0] != '~':
# the second condition is to ensure that (hidden) file variables are not deleted,
# as these are in orig_metadata, not track_metadata
delete_list.append(tag_item)
# this will be used in map_tags to delete unwanted tags
options['delete_tags'] = delete_list
## Create a "mirror" tag with the old data, for comparison purposes
mirror_tags = []
for tag_item in orig_metadata:
mirror_name = tag_item + '_OLD'
if mirror_name[0] == '~' :
mirror_name.replace('~', '_')
mirror_name = '~' + mirror_name
mirror_tags.append((mirror_name, tag_item))
append_tag(release_id, tm, mirror_name, orig_metadata[tag_item])
append_tag(release_id, tm, '~ce_mirror_tags', mirror_tags)
if not isinstance(options, dict):
options_dict = option_settings(config.setting)
write_log(
'session',
'info',
'Using option_settings(config.setting): %s',
options_dict)
else:
options_dict = options
write_log(
'session',
'info',
'Using options: %s',
options_dict)
tm['~ce_options'] = str(options_dict)
tm['~ce_file'] = music_file_found
def plugin_options(option_type):
"""
:param option_type: artists, tag, workparts, genres or other
:return: the relevant dictionary for the type
This function contains all the options data in one place - to prevent multiple repetitions elsewhere
"""
if option_type == 'artists':
return const.ARTISTS_OPTIONS
elif option_type == 'tag':
return const.TAG_OPTIONS
elif option_type == 'tag_detail':
return const.TAG_DETAIL_OPTIONS
elif option_type == 'workparts':
return const.WORKPARTS_OPTIONS
elif option_type == 'genres':
return const.GENRE_OPTIONS
elif option_type == 'picard':
return const.PICARD_OPTIONS
elif option_type == 'other':
return const.OTHER_OPTIONS
else:
return None
def option_settings(config_settings):
"""
:param config_settings: options from UI
:return: a (deep) copy of the Classical Extras options
"""
options = {}
for option in plugin_options('artists') + plugin_options('tag') + plugin_options('tag_detail') + plugin_options(
'workparts') + plugin_options('genres') + plugin_options('picard') + plugin_options('other'):
options[option['option']] = copy.deepcopy(
config_settings[option['option']])
return options
def get_aliases(self, release_id, album, options, releaseXmlNode):
"""
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param self:
:param album:
:param options:
:param releaseXmlNode: all the metadata for the release
:return: Data is returned via self.artist_aliases and self.artist_credits[album]
Note regarding aliases and credited-as names:
In a MB release, an artist can appear in one of seven contexts. Each of these is accessible in releaseXmlNode
and the track and recording contexts are also accessible in trackXmlNode.
The seven contexts are:
Recording: credited-as and alias
Release-group: credited-as and alias
Release: credited-as and alias
Release relationship: credited-as and (not reliably?) alias
Recording relationship (direct): credited-as and (not reliably?) alias
Recording relationship (via work): credited-as and (not reliably?) alias
Track: credited-as and alias
(The above are applied in sequence - e.g. track artist credit will over-ride release artist credit. "Recording" gets
the lowest priority as it is more generic than the release data {may apply to multiple releases})
This function collects all the available aliases and as-credited names once (on processing the first track).
N.B. if more than one release is loaded in Picard, any available alias names loaded so far will be available
and used. However, as-credited names will only be used from the current release."""
lang = get_preferred_artist_language(config)
if lang and options['cea_aliases'] or options['cea_aliases_composer']:
# Track and recording aliases/credits are gathered by parsing the
# media, track and recording nodes
# Do the recording relationship first as it may apply to multiple releases, so release and track data
# is more specific.
media = parse_data(release_id, releaseXmlNode, [], 'media')
for m in media:
# disc_num = int(parse_data(options, m, [], 'position', 'text')[0])
# not currently used
tracks = parse_data(release_id, m, [], 'tracks')
for track in tracks:
for t in track:
# track_num = int(parse_data(options, t, [], 'number',
# 'text')[0]) # not currently used
# Recording artists
obj = parse_data(release_id, t, [], 'recording')
get_aliases_and_credits(
self,
options,
release_id,
album,
obj,
lang,
options['cea_recording_credited'])
# Get the release data before the recording relationshiops and track data
# Release group artists
obj = parse_data(release_id, releaseXmlNode, [], 'release-group')
get_aliases_and_credits(
self,
options,
release_id,
album,
obj,
lang,
options['cea_group_credited'])
# Release artists
get_aliases_and_credits(
self,
options,
release_id,
album,
releaseXmlNode,
lang,
options['cea_credited'])
# Next bit needed to identify artists who are album artists
self.release_artists_sort[album] = parse_data(
release_id, releaseXmlNode, [], 'artist-credit', 'artist', 'sort-name')
# Release relationship artists
get_relation_credits(
self,
options,
release_id,
album,
releaseXmlNode,
lang,
options['cea_release_relationship_credited'])
# Now get the rest:
for m in media:
tracks = parse_data(release_id, m, [], 'tracks')
for track in tracks:
for t in track:
# Recording relationship artists
obj = parse_data(release_id, t, [], 'recording')
get_relation_credits(
self,
options,
release_id,
album,
obj,
lang,
options['cea_recording_relationship_credited'])
# Track artists
get_aliases_and_credits(
self,
options,
release_id,
album,
t,
lang,
options['cea_track_credited'])
if options['log_info']:
write_log(release_id, 'info', 'Alias and credits info for %s', self)
write_log(release_id, 'info', 'Aliases :%s', self.artist_aliases)
write_log(
release_id,
'info',
'Credits :%s',
self.artist_credits[album])
def get_artists(options, release_id, tm, relations, relation_type):
"""
Get artist info from XML lookup
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param options:
:param tm:
:param relations:
:param relation_type: 'release', 'recording' or 'work' (NB 'work' does not pass a param for tm)
:return:
"""
if options['log_debug'] or options['log_info']:
write_log(
release_id,
'debug',
'In get_artists. relation_type: %s, relations: %s',
relation_type,
relations)
log_options = {
'log_debug': options['log_debug'],
'log_info': options['log_info']}
artists = []
instruments = []
artist_types = const.RELATION_TYPES[relation_type]
for artist_type in artist_types:
artists, instruments = create_artist_data(release_id, options, log_options, tm, relations,
relation_type, artist_type, artists, instruments)
artist_dict = {'artists': artists, 'instruments': instruments}
return artist_dict
def create_artist_data(release_id, options, log_options, tm, relations,
relation_type, artist_type, artists, instruments):
"""
Update the artists and instruments
:param release_id: the current album id
:param options:
:param log_options:
:param tm: track metadata
:param relations:
:param relation_type: release', 'recording' or 'work' (NB 'work' does not pass a param for tm)
:param artist_type: from const.RELATION_TYPES[relation_type]
:param artists: current artist list - updated with each call
:param instruments: current instruments list - updated with each call
:return: artists, instruments
"""
type_list = parse_data(
release_id,
relations,
[],
'target-type:artist',
'type:' +
artist_type)
for type_item in type_list:
artist_name_list = parse_data(
release_id, type_item, [], 'artist', 'name')
artist_sort_name_list = parse_data(
release_id, type_item, [], 'artist', 'sort-name')
if artist_type not in [
'instrument',
'vocal',
'instrument arranger',
'vocal arranger']:
instrument_list = None
credited_inst_list = None
else:
instrument_list_list = parse_data(
release_id, type_item, [], 'attributes')
if instrument_list_list:
instrument_list = instrument_list_list[0]
else:
instrument_list = []
credited_inst_list = instrument_list[:]
credited_inst_dict_list = parse_data(
release_id, type_item, [], 'attribute-credits') # keyed to insts
if credited_inst_dict_list:
credited_inst_dict = credited_inst_dict_list[0]
else:
credited_inst_dict = {}
for i, inst in enumerate(instrument_list):
if inst in credited_inst_dict:
credited_inst_list[i] = credited_inst_dict[inst]
if artist_type == 'vocal':
if not instrument_list:
instrument_list = ['vocals']
elif not any('vocals' in x for x in instrument_list):
instrument_list.append('vocals')
credited_inst_list.append('vocals')
# fill the hidden vars before we choose to use the as-credited
# version
if relation_type != 'work':
inst_tag = []
cred_tag = []
if instrument_list:
inst_tag = list(set(instrument_list))
if credited_inst_list:
cred_tag = list(set(credited_inst_list))
for attrib in ['solo', 'guest', 'additional']:
if attrib in inst_tag:
inst_tag.remove(attrib)
if attrib in cred_tag:
cred_tag.remove(attrib)
if inst_tag:
if tm['~cea_instruments']:
tm['~cea_instruments'] = add_list_uniquely(
tm['~cea_instruments'], inst_tag)
else:
tm['~cea_instruments'] = inst_tag
if cred_tag:
if tm['~cea_instruments_credited']:
tm['~cea_instruments_credited'] = add_list_uniquely(
tm['~cea_instruments_credited'], cred_tag)
else:
tm['~cea_instruments_credited'] = cred_tag
if inst_tag or cred_tag:
if tm['~cea_instruments_all']:
tm['~cea_instruments_all'] = add_list_uniquely(
tm['~cea_instruments_all'], list(set(inst_tag + cred_tag)))
else:
tm['~cea_instruments_all'] = list(
set(inst_tag + cred_tag))
if '~cea_instruments' in tm and '~cea_instruments_credited' in tm and '~cea_instruments_all' in tm:
instruments = [
tm['~cea_instruments'],
tm['~cea_instruments_credited'],
tm['~cea_instruments_all']]
if options['cea_inst_credit'] and credited_inst_list:
instrument_list = credited_inst_list
if instrument_list:
instrument_sort = 3
s_key = {
'lead vocals': 1,
'solo': 2,
'guest': 4,
'additional': 5}
for inst in s_key:
if inst in instrument_list:
instrument_sort = s_key[inst]
else:
instrument_sort = 0
if artist_type in const.ARTIST_TYPE_ORDER:
type_sort = const.ARTIST_TYPE_ORDER[artist_type]
else:
type_sort = 99
if log_options['log_error']:
write_log(
release_id,
'error',
"Error in artist type. Type '%s' is not in ARTIST_TYPE_ORDER dictionary",
artist_type)
artist = (
artist_type,
instrument_list,
artist_name_list,
artist_sort_name_list,
instrument_sort,
type_sort)
artists.append(artist)
# Sorted by sort name then instrument_sort then artist type
artists = sorted(artists, key=lambda x: (x[5], x[3], x[4], x[1]))
if log_options['log_info']:
write_log(release_id, 'info', 'sorted artists = %s', artists)
return artists, instruments
def get_series(options, release_id, relations):
"""
Get series info (depends on lookup having used inc=series-rel)
:param options:
:param release_id:
:param relations:
:return:
"""
# if options['log_debug'] or options['log_info']:
# write_log(
# release_id,
# 'debug',
# 'In get_series. relations: %s',
# relations)
# series_name_list =[]
# series_id_list = []
# for series_rels in relations:
# series_rel = parse_data(
# release_id,
# series_rels,
# [],
# 'target-type:series',
# 'type:part-of')
# if options['log_debug'] or options['log_info']:
# write_log(
# release_id,
# 'debug',
# 'series_rel = %s',
# series_rel)
# series_name_list.extend(
# parse_data(release_id, series_rel, [], 'series', 'name')
# )
# series_id_list.extend(
# parse_data(release_id, series_rel, [], 'series', 'id')
# )
type_list = parse_data(
release_id,
relations,
[],
'target-type:series',
'type:part of')
if type_list:
series_name_list = []
series_id_list = []
series_number_list = []
for type_item in type_list:
series_name_list = parse_data(
release_id, type_item, [], 'series', 'name')
series_id_list = parse_data(
release_id, type_item, [], 'series', 'id')
series_number_list = parse_data(
release_id, type_item, [], 'attribute-values', 'number')
return {'name_list': series_name_list, 'id_list': series_id_list, 'number_list': series_number_list}
else:
return None
def apply_artist_style(
options,
release_id,
lang,
a_list,
name_style,
name_tag,
sort_tag,
names_tag,
names_sort_tag):
# Get artist and apply style
for a_item in a_list:
for acs in a_item:
artistlist = parse_data(release_id, acs, [], 'name')
sortlist = parse_data(release_id, acs, [], 'artist', 'sort-name')
names = {}
if lang:
names['alias'] = parse_data(
release_id,
acs,
[],
'artist',
'aliases',
'locale:' + lang,
'primary:True',
'name')
else:
names['alias'] = []
names['credit'] = parse_data(release_id, acs, [], 'name')
pairslist = list(zip(artistlist, sortlist))
names['sort'] = [
translate_from_sortname(
*pair) for pair in pairslist]
for style in name_style:
if names[style]:
artistlist = names[style]
break
joinlist = parse_data(release_id, acs, [], 'joinphrase')
if artistlist:
name_tag.append(artistlist[0])
sort_tag.append(sortlist[0])
names_tag.append(artistlist[0])
names_sort_tag.append(sortlist[0])
if joinlist:
name_tag.append(joinlist[0])
sort_tag.append(joinlist[0])
name_tag_str = ''.join(name_tag)
sort_tag_str = ''.join(sort_tag)
return {
'artists': names_tag,
'artists_sort': names_sort_tag,
'artist': name_tag_str,
'artistsort': sort_tag_str}
def set_work_artists(self, release_id, album, track, writerList, tm, count):
"""
:param release_id:
:param self is the calling object from Artists or WorkParts
:param album: the current album
:param track: the current track
:param writerList: format [(artist_type, [instrument_list], [name list],[sort_name list]),(.....etc]
:param tm: track metadata
:param count: depth count of recursion in process_work_artists (should equate to part level)
:return:
"""
options = self.options[track]
if not options['classical_work_parts']:
caller = 'ExtraArtists'
pre = '~cea'
else:
caller = 'PartLevels'
pre = '~cwp'
write_log(
release_id,
'debug',
'Class: %s: in set_work_artists for track %s. Count (level) is %s. Writer list is %s',
caller,
track,
count,
writerList)
# tag strings are a tuple (Picard tag, cwp tag, Picard sort tag, cwp sort
# tag) (NB this is modelled on set_performer)
tag_strings = const.tag_strings(pre)
# insertions lists artist types where names in the main Picard tags may be
# updated for annotations
insertions = const.INSERTIONS
no_more_lyricists = False
if caller == 'PartLevels' and self.lyricist_filled[track]:
no_more_lyricists = True
for writer in writerList:
writer_type = writer[0]
if writer_type not in tag_strings:
break
if no_more_lyricists and (
writer_type == 'lyricist' or writer_type == 'librettist'):
break
if writer[1]:
inst_list = writer[1][:]
# take a copy of the list in case (because of list
# mutability) we need the old one
instrument = ", ".join(inst_list)
else:
instrument = None
sub_strings = { # 'instrument arranger': instrument, 'vocal arranger': instrument
}
if options['cea_arranger']:
if instrument:
arr_inst = options['cea_arranger'] + ' ' + instrument
else:
arr_inst = options['cea_arranger']
else:
arr_inst = instrument
annotations = {'writer': options['cea_writer'],
'lyricist': options['cea_lyricist'],
'librettist': options['cea_librettist'],
'revised by': options['cea_revised'],
'translator': options['cea_translator'],
'arranger': options['cea_arranger'],
'reconstructed by': options['cea_reconstructed'],
'orchestrator': options['cea_orchestrator'],
'instrument arranger': arr_inst,
'vocal arranger': arr_inst}
tag = tag_strings[writer_type][0]
sort_tag = tag_strings[writer_type][2]
cwp_tag = tag_strings[writer_type][1]
cwp_sort_tag = tag_strings[writer_type][3]
cwp_names_tag = cwp_tag[:-1] + '_names'
cwp_instrumented_tag = cwp_names_tag + '_instrumented'
if writer_type in sub_strings:
if sub_strings[writer_type]:
tag += sub_strings[writer_type]
if tag:
if '~ce_tag_cleared_' + \
tag not in tm or not tm['~ce_tag_cleared_' + tag] == "Y":
if tag in tm:
if options['log_info']:
write_log(release_id, 'info', 'delete tag %s', tag)
del tm[tag]
tm['~ce_tag_cleared_' + tag] = "Y"
if sort_tag:
if '~ce_tag_cleared_' + \
sort_tag not in tm or not tm['~ce_tag_cleared_' + sort_tag] == "Y":
if sort_tag in tm:
del tm[sort_tag]
tm['~ce_tag_cleared_' + sort_tag] = "Y"
name_list = writer[2]
for ind, name in enumerate(name_list):
sort_name = writer[3][ind]
no_credit = True
write_log(
release_id,
'info',
'In set_work_artists. Name before changes = %s',
name)
# change name to as-credited
if options['cea_composer_credited']:
if album in self.artist_credits and sort_name in self.artist_credits[album]:
no_credit = False
name = self.artist_credits[album][sort_name]
# over-ride with aliases if appropriate
if (options['cea_aliases'] or options['cea_aliases_composer']) and (
no_credit or options['cea_alias_overrides']):
if sort_name in self.artist_aliases:
name = self.artist_aliases[sort_name]
# fix cyrillic names if not already fixed
if options['cea_cyrillic']:
if not only_roman_chars(name):
name = remove_middle(unsort(sort_name))
# Only remove middle name where the existing
# performer is in non-latin script
annotated_name = name
write_log(
release_id,
'info',
'In set_work_artists. Name after changes = %s',
name)
# add annotations and write performer tags
if writer_type in annotations:
if annotations[writer_type]:
annotated_name += ' (' + annotations[writer_type] + ')'
if instrument:
instrumented_name = name + ' (' + instrument + ')'
else:
instrumented_name = name
if writer_type in insertions and options['cea_arrangers']:
self.append_tag(release_id, tm, tag, annotated_name)
else:
if options['cea_arrangers'] or writer_type == tag:
self.append_tag(release_id, tm, tag, name)
if options['cea_arrangers'] or writer_type == tag:
if sort_tag:
self.append_tag(release_id, tm, sort_tag, sort_name)
if options['cea_tag_sort'] and '~' in sort_tag:
explicit_sort_tag = sort_tag.replace('~', '')
self.append_tag(
release_id, tm, explicit_sort_tag, sort_name)
self.append_tag(release_id, tm, cwp_tag, annotated_name)
self.append_tag(release_id, tm, cwp_names_tag, name)
if instrumented_name != name:
self.append_tag(
release_id,
tm,
cwp_instrumented_tag,
instrumented_name)
if cwp_sort_tag:
self.append_tag(release_id, tm, cwp_sort_tag, sort_name)
if caller == 'PartLevels' and (
writer_type == 'lyricist' or writer_type == 'librettist'):
self.lyricist_filled[track] = True
write_log(
release_id,
'info',
'Filled lyricist for track %s. Not looking further',
track)
if writer_type == 'composer':
composerlast = sort_name.split(",")[0]
write_log(
release_id,
'info',
'composerlast = %s',
composerlast)
self.append_tag(
release_id,
tm,
pre +
'_composer_lastnames',
composerlast)
if sort_name in self.release_artists_sort[album]:
self.append_tag(
release_id, tm, '~cea_album_composers', name)
self.append_tag(
release_id, tm, '~cea_album_composers_sort', sort_name)
self.append_tag(
release_id,
tm,
'~cea_album_track_composer_lastnames',
composerlast)
composer_last_names(self, release_id, tm, album)
# Non-Latin character processing
latin_letters = {}
def is_latin(uchr):
"""Test whether character is in Latin script"""
try:
return latin_letters[uchr]
except KeyError:
return latin_letters.setdefault(
uchr, 'LATIN' in unicodedata.name(uchr))
def only_roman_chars(unistr):
"""Test whether string is in Latin script"""
return all(is_latin(uchr)
for uchr in unistr
if uchr.isalpha())
def get_roman(string):
"""Transliterate cyrillic script to Latin script"""
translit_string = ""
for index, char in enumerate(string):
if char in const.CYRILLIC_LOWER.keys():
char = const.CYRILLIC_LOWER[char]
elif char in const.CYRILLIC_UPPER.keys():
char = const.CYRILLIC_UPPER[char]
if string[index + 1] not in const.CYRILLIC_LOWER.keys():
char = char.upper()
translit_string += char
# fix multi-chars
translit_string = translit_string.replace('ks', 'x').replace('iy ', 'i ')
return translit_string
def remove_middle(performer):
"""To remove middle names of Russian composers"""
plist = performer.split()
if len(plist) == 3:
return plist[0] + ' ' + plist[2]
else:
return performer
# Sorting etc.
def unsort(performer):
"""
To take a sort field and recreate the name
Only now used for last-ditch cyrillic translation - superseded by 'translate_from_sortname'
"""
sorted_list = performer.split(', ')
sorted_list.reverse()
for i, item in enumerate(sorted_list):
if item[-1] != "'":
sorted_list[i] += ' '
return ''.join(sorted_list).strip()
def _reverse_sortname(sortname):
"""
Reverse sortnames.
Code is from picard/util/__init__.py
"""
chunks = [a.strip() for a in sortname.split(",")]
chunk_len = len(chunks)
if chunk_len == 2:
return "%s %s" % (chunks[1], chunks[0])
elif chunk_len == 3:
return "%s %s %s" % (chunks[2], chunks[1], chunks[0])
elif chunk_len == 4:
return "%s %s, %s %s" % (chunks[1], chunks[0], chunks[3], chunks[2])
else:
return sortname.strip()
def stripsir(performer):
"""
Remove honorifics from names
Also standardize hyphens and apostrophes in names
"""
performer = performer.replace(u'\u2010', u'-').replace(u'\u2019', u"'")
sir = re.compile(r'(.*)\b(Sir|Maestro|Dame)\b\s*(.*)', re.IGNORECASE)
match = sir.search(performer)
if match:
return match.group(1) + match.group(3)
else:
return performer
# def swap_prefix(performer):
# """NOT CURRENTLY USED. Create sort fields for ensembles etc., by placing the prefix (see constants) at the end"""
# prefix = '|'.join(prefixes)
# swap = re.compile(r'^(' + prefix + r')\b\s*(.*)', re.IGNORECASE)
# match = swap.search(performer)
# if match:
# return match.group(2) + ", " + match.group(1)
# else:
# return performer
def replace_roman_numerals(s):
"""Replaces roman numerals include in s, where followed by certain punctuation, by digits"""
romans = RE_ROMANS.findall(s)
for roman in romans:
if roman[0]:
numerals = str(roman[0])
digits = str(from_roman(numerals))
to_replace = r'\b' + roman[0] + r'\b'
s = re.sub(to_replace, digits, s)
return s
def from_roman(s):
romanNumeralMap = (('M', 1000),
('CM', 900),
('D', 500),
('CD', 400),
('C', 100),
('XC', 90),
('L', 50),
('XL', 40),
('X', 10),
('IX', 9),
('V', 5),
('IV', 4),
('I', 1),
('m', 1000),
('cm', 900),
('d', 500),
('cd', 400),
('c', 100),
('xc', 90),
('l', 50),
('xl', 40),
('x', 10),
('ix', 9),
('v', 5),
('iv', 4),
('i', 1))
result = 0
index = 0
for numeral, integer in romanNumeralMap:
while s[index:index + len(numeral)] == numeral:
result += integer
index += len(numeral)
return result
def turbo_lcs(release_id, multi_list):
"""
Picks the best longest common string method to use
Works with a list of lists or a list of strings
:param release_id:
:param multi_list: a list of strings or a list of lists
:return: longest common substring/list
"""
write_log(release_id, 'debug', 'In turbo_lcs')
if not isinstance(multi_list, list):
return None
list_sum = sum([len(x) for x in multi_list])
list_len = len(multi_list)
if list_len < 2:
if list_len == 1:
return multi_list[0] # Nothing to do!
else:
return []
# for big matches, use the generalised suffix tree method
if ((list_sum / list_len) ** 2) * list_len > 1000:
# heuristic: may need to tweak the 1000 in the light of results
lcs_dict = suffixtree.multi_lcs(multi_list)
# NB suffixtree may be shown as an unresolved reference in the IDE,
# but it should work provided it is included in the package
if "error" not in lcs_dict:
if "response" in lcs_dict:
write_log(
release_id,
'info',
'Longest common string was returned from suffix tree algo')
return lcs_dict['response']
## If suffix tree fails, write errors to log before proceeding with alternative
else:
write_log(
release_id,
'error',
'Suffix tree failure for release %s. Error unknown. Using standard lcs algo instead',
release_id)
else:
write_log(
release_id,
'error',
'Suffix tree failure for release %s. Error message: %s. Using standard lcs algo instead',
release_id,
lcs_dict['error'])
# otherwise, or if gst fails, use the standard algorithm
first = True
common = []
for item in multi_list:
if first:
common = item
first = False
else:
lcs = longest_common_substring(
item, common)
common = lcs['string']
write_log(release_id, 'debug', 'LCS returned from standard algo')
return common
def longest_common_substring(s1, s2):
"""
Standard lcs algo for short strings, or if suffix tree does not work
:param s1: substring 1
:param s2: substring 2
:return: {'string': the longest common substring,
'start': the start position in s1,
'length': the length of the common substring}
NB this also works on list arguments - i.e. it will find the longest common sub-list
"""
m = [[0] * (1 + len(s2)) for i in range(1 + len(s1))]
longest, x_longest = 0, 0
for x in range(1, 1 + len(s1)):
for y in range(1, 1 + len(s2)):
if s1[x - 1] == s2[y - 1]:
m[x][y] = m[x - 1][y - 1] + 1
if m[x][y] > longest:
longest = m[x][y]
x_longest = x
else:
m[x][y] = 0
return {'string': s1[x_longest - longest: x_longest],
'start': x_longest - longest, 'length': longest}
def longest_common_sequence(list1, list2, minstart=0, maxstart=0):
"""
:param list1: list 1
:param list2: list 2
:param minstart: the earliest point to start looking for a match
:param maxstart: the latest point to start looking for a match
:return: {'sequence': the common subsequence, 'length': length of subsequence}
maxstart must be >= minstart. If they are equal then the start point is fixed.
Note that this only finds subsequences starting at the same position
Use longest_common_substring for the more general problem
"""
if maxstart < minstart:
return None, 0
min_len = min(len(list1), len(list2))
longest = 0
seq = None
maxstart = min(maxstart, min_len) + 1
for k in range(minstart, maxstart):
for i in range(k, min_len + 1):
if list1[k:i] == list2[k:i] and i - k > longest:
longest = i - k
seq = list1[k:i]
return {'sequence': seq, 'length': longest}
def substart_finder(mylist, pattern):
for i, list_item in enumerate(mylist):
if list_item == pattern[0] and mylist[i:i + len(pattern)] == pattern:
return i
return len(mylist) # if nothing found
def get_ui_tags():
## Determine tags for display in ui
options = config.setting
ui_tags_raw = options['ce_ui_tags']
ui_tags = {}
ui_tags_split = [x.replace('(','').strip(') ') for x in ui_tags_raw.split('/')]
for ui_column in ui_tags_split:
if ':' in ui_column:
ui_col_parts = [x.strip() for x in ui_column.split(':')]
heading = ui_col_parts[0]
tag_names = ui_col_parts[1].split(',')
tag_names = [x.strip() for x in tag_names]
ui_tags[heading] = tuple(tag_names)
return ui_tags
def map_tags(options, release_id, album, tm):
"""
Do the common tag processing - including for the genres and tag-mapping sections
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param options: options passed from either Artists or Workparts
:param album:
:param tm: track metadata
:return: None - action is through setting tm contents
This is a common function for Artists and Workparts which should only run after both sections have completed for
a given track. If, say, Artists calls it and Workparts is not done,
then it will not execute until Workparts calls it (and vice versa).
"""
write_log(release_id, 'debug', 'In map_tags, checking readiness...')
if (options['classical_extra_artists'] and '~cea_artists_complete' not in tm) or (
options['classical_work_parts'] and '~cea_works_complete' not in tm):
write_log(release_id, 'info', '...not ready')
return
write_log(release_id, 'debug', '... processing tag mapping')
# blank tags
blank_tags = options['cea_blank_tag'].split(
",") + options['cea_blank_tag_2'].split(",")
if 'artists_sort' in [x.strip() for x in blank_tags]:
blank_tags.append('~artists_sort')
for tag in blank_tags:
if tag.strip() in tm:
# place blanked tags into hidden variables available for
# re-use
tm['~cea_' + tag.strip()] = tm[tag.strip()]
del tm[tag.strip()]
# album
if tm['~cea_album_composer_lastnames']:
last_names = str_to_list(tm['~cea_album_composer_lastnames'])
if options['cea_composer_album']:
# save it as a list to prevent splitting when appending tag
tm['~cea_release'] = [tm['album']]
new_last_names = []
for last_name in last_names:
last_name = last_name.strip()
new_last_names.append(last_name)
if len(new_last_names) > 0:
tm['album'] = "; ".join(new_last_names) + ": " + tm['album']
# remove lyricists if no vocals, according to option set
if options['cea_no_lyricists'] and not any(
[x for x in str_to_list(tm['~cea_performers']) if 'vocals' in x]):
if 'lyricist' in tm:
del tm['lyricist']
for lyricist_tag in ['lyricists', 'librettists', 'translators']:
if '~cwp_' + lyricist_tag in tm:
del tm['~cwp_' + lyricist_tag]
# genres
if config.setting['folksonomy_tags'] and 'genre' in tm:
candidate_genres = str_to_list(tm['genre'])
append_tag(release_id, tm, '~cea_candidate_genres', candidate_genres)
# to avoid confusion as it will contain unmatched folksonomy tags
del tm['genre']
else:
candidate_genres = []
is_classical = False
composers_not_found = []
composer_found = False
composer_born_list = []
composer_died_list = []
arrangers_not_found = []
arranger_found = False
arranger_born_list = []
arranger_died_list = []
no_composer_in_metadata = False
if options['cwp_use_muso_refdb'] and options['cwp_muso_classical'] or options['cwp_muso_dates']:
if COMPOSER_DICT:
composersort_list = []
if '~cwp_composer_names' in tm:
composer_list = str_to_list(tm['~cwp_composer_names'])
else:
# maybe there were no works linked,
# but it might still a classical track (based on composer name)
no_composer_in_metadata = True
composer_list = str_to_list(tm['artists'])
composersort_list = str_to_list(tm['~artists_sort'])
write_log(release_id, 'info', "No composer metadata for track %s. Using artists %r", tm['title'],
composer_list)
lc_composer_list = [c.lower() for c in composer_list]
for ind, composer in enumerate(lc_composer_list):
for classical_composer in COMPOSER_DICT:
if composer in classical_composer['lc_name']:
if options['cwp_muso_classical']:
candidate_genres.append('Classical')
is_classical = True
if options['cwp_muso_dates']:
composer_born_list = classical_composer['birth']
composer_died_list = classical_composer['death']
composer_found = True
if no_composer_in_metadata:
composersort = composersort_list[ind]
append_tag(release_id, tm, 'composer', composer_list[ind])
append_tag(release_id, tm, '~cwp_composer_names', composer_list[ind])
append_tag(release_id, tm, 'composersort', composersort)
append_tag(release_id, tm, '~cwp_composers_sort', composersort)
append_tag(release_id, tm, '~cwp_composer_lastnames', composersort.split(', ')[0])
break
if not composer_found:
composer_index = lc_composer_list.index(composer)
orig_composer = composer_list[composer_index]
composers_not_found.append(orig_composer)
append_tag(
release_id,
tm,
'~cwp_unrostered_composers',
orig_composer)
if composers_not_found:
append_tag(
release_id,
tm,
'003_information:composers',
'Composer(s) ' +
list_to_str(composers_not_found) +
' not found in reference database of classical composers')
# do the same for arrangers, if required
if options['cwp_genres_arranger_as_composer'] or options['cwp_periods_arranger_as_composer']:
arranger_list = str_to_list(
tm['~cea_arranger_names']) + str_to_list(tm['~cwp_arranger_names'])
lc_arranger_list = [c.lower() for c in arranger_list]
for arranger in lc_arranger_list:
for classical_arranger in COMPOSER_DICT:
if arranger in classical_arranger['lc_name']:
if options['cwp_muso_classical'] and options['cwp_genres_arranger_as_composer']:
candidate_genres.append('Classical')
is_classical = True
if options['cwp_muso_dates'] and options['cwp_periods_arranger_as_composer']:
arranger_born_list = classical_arranger['birth']
arranger_died_list = classical_arranger['death']
arranger_found = True
break
if not arranger_found:
arranger_index = lc_arranger_list.index(arranger)
orig_arranger = arranger_list[arranger_index]
arrangers_not_found.append(orig_arranger)
append_tag(
release_id,
tm,
'~cwp_unrostered_arrangers',
orig_arranger)
if arrangers_not_found:
append_tag(
release_id,
tm,
'003_information:arrangers',
'Arranger(s) ' +
list_to_str(arrangers_not_found) +
' not found in reference database of classical composers')
else:
append_tag(
release_id,
tm,
'001_errors:8',
'8. No composer reference file. Check log for error messages re path name.')
if options['cwp_use_muso_refdb'] and options['cwp_muso_genres'] and GENRE_DICT:
main_classical_genres_list = [list_to_str(
mg['name']).strip() for mg in GENRE_DICT]
else:
main_classical_genres_list = [
sg.strip() for sg in options['cwp_genres_classical_main'].split(',')]
sub_classical_genres_list = [
sg.strip() for sg in options['cwp_genres_classical_sub'].split(',')]
main_other_genres_list = [
sg.strip() for sg in options['cwp_genres_other_main'].split(',')]
sub_other_genres_list = [sg.strip()
for sg in options['cwp_genres_other_sub'].split(',')]
main_classical_genres = []
sub_classical_genres = []
main_other_genres = []
sub_other_genres = []
if '~cea_work_type' in tm:
candidate_genres += str_to_list(tm['~cea_work_type'])
if '~cwp_candidate_genres' in tm:
candidate_genres += str_to_list(tm['~cwp_candidate_genres'])
write_log(release_id, 'info', "Candidate genres: %r", candidate_genres)
untagged_genres = []
if candidate_genres:
main_classical_genres = [
val for val in main_classical_genres_list if val.lower() in [
genre.lower() for genre in candidate_genres]]
sub_classical_genres = [
val for val in sub_classical_genres_list if val.lower() in [
genre.lower() for genre in candidate_genres]]
if main_classical_genres or sub_classical_genres or options['cwp_genres_classical_all']:
is_classical = True
main_classical_genres.append('Classical')
candidate_genres.append('Classical')
write_log(release_id, 'info', "Main classical genres for track %s: %r", tm['title'], main_classical_genres)
candidate_genres += str_to_list(tm['~cea_work_type_if_classical'])
# next two are repeated statements, but a separate fn would be
# clumsy too!
main_classical_genres = [
val for val in main_classical_genres_list if val.lower() in [
genre.lower() for genre in candidate_genres]]
sub_classical_genres = [
val for val in sub_classical_genres_list if val.lower() in [
genre.lower() for genre in candidate_genres]]
if options['cwp_genres_classical_exclude']:
main_classical_genres = [
g for g in main_classical_genres if g.lower() != 'classical']
main_other_genres = [
val for val in main_other_genres_list if val.lower() in [
genre.lower() for genre in candidate_genres]]
sub_other_genres = [
val for val in sub_other_genres_list if val.lower() in [
genre.lower() for genre in candidate_genres]]
all_genres = main_classical_genres + sub_classical_genres + \
main_other_genres + sub_other_genres
untagged_genres = [
un for un in candidate_genres if un.lower() not in [
genre.lower() for genre in all_genres]]
if options['cwp_genre_tag']:
if not options['cwp_genres_filter']:
append_tag(
release_id,
tm,
options['cwp_genre_tag'],
candidate_genres)
else:
append_tag(
release_id,
tm,
options['cwp_genre_tag'],
main_classical_genres +
main_other_genres)
if options['cwp_subgenre_tag'] and options['cwp_genres_filter']:
append_tag(
release_id,
tm,
options['cwp_subgenre_tag'],
sub_classical_genres +
sub_other_genres)
if is_classical and options['cwp_genres_flag_text'] and options['cwp_genres_flag_tag']:
tm[options['cwp_genres_flag_tag']] = options['cwp_genres_flag_text']
if not (
main_classical_genres +
main_other_genres)and options['cwp_genres_filter']:
if options['cwp_genres_default']:
append_tag(
release_id,
tm,
options['cwp_genre_tag'],
options['cwp_genres_default'])
else:
if options['cwp_genre_tag'] in tm:
del tm[options['cwp_genre_tag']]
if untagged_genres and options['cwp_genres_filter']:
append_tag(
release_id,
tm,
'003_information:genres',
'Candidate genres found but not matched: ' +
list_to_str(untagged_genres))
append_tag(release_id, tm, '~cwp_untagged_genres', untagged_genres)
# instruments and keys
if options['cwp_instruments_MB_names'] and options['cwp_instruments_credited_names'] and tm['~cea_instruments_all']:
instruments = str_to_list(tm['~cea_instruments_all'])
elif options['cwp_instruments_MB_names'] and tm['~cea_instruments']:
instruments = str_to_list(tm['~cea_instruments'])
elif options['cwp_instruments_credited_names'] and tm['~cea_instruments_credited']:
instruments = str_to_list(tm['~cea_instruments_credited'])
else:
instruments = None
if instruments and options['cwp_instruments_tag']:
append_tag(release_id, tm, options['cwp_instruments_tag'], instruments)
# need to append rather than over-write as it may be the same as
# another tag (e.g. genre)
if tm['~cwp_keys'] and options['cwp_key_tag']:
append_tag(release_id, tm, options['cwp_key_tag'], tm['~cwp_keys'])
# dates
if options['cwp_workdate_annotate']:
comp = ' (composed)'
publ = ' (published)'
prem = ' (premiered)'
else:
comp = ''
publ = ''
prem = ''
tm[options['cwp_workdate_tag']] = ''
earliest_date = 9999
latest_date = -9999
found = False
if tm['~cwp_composed_dates']:
composed_dates_list = str_to_list(tm['~cwp_composed_dates'])
if len(composed_dates_list) > 1:
composed_dates_list = str_to_list(
composed_dates_list[0]) # use dates of lowest-level work
earliest_date = min([int(dates.split(DATE_SEP)[0].strip())
for dates in composed_dates_list])
append_tag(
release_id,
tm,
options['cwp_workdate_tag'],
list_to_str(composed_dates_list) +
comp)
found = True
if tm['~cwp_published_dates'] and (
not found or options['cwp_workdate_use_all']):
if not found:
published_dates_list = str_to_list(tm['~cwp_published_dates'])
if len(published_dates_list) > 1:
published_dates_list = str_to_list(
published_dates_list[0]) # use dates of lowest-level work
earliest_date = min([int(dates.split(DATE_SEP)[0].strip())
for dates in published_dates_list])
append_tag(
release_id,
tm,
options['cwp_workdate_tag'],
list_to_str(published_dates_list) +
publ)
found = True
if tm['~cwp_premiered_dates'] and (
not found or options['cwp_workdate_use_all']):
if not found:
premiered_dates_list = str_to_list(tm['~cwp_premiered_dates'])
if len(premiered_dates_list) > 1:
premiered_dates_list = str_to_list(
premiered_dates_list[0]) # use dates of lowest-level work
earliest_date = min([int(dates.split(DATE_SEP)[0].strip())
for dates in premiered_dates_list])
append_tag(
release_id,
tm,
options['cwp_workdate_tag'],
list_to_str(premiered_dates_list) +
prem)
# periods
PERIODS = {}
if options['cwp_period_map']:
if options['cwp_use_muso_refdb'] and options['cwp_muso_periods'] and PERIOD_DICT:
for p_item in PERIOD_DICT:
if 'start' not in p_item or p_item['start'] == []:
p_item['start'] = [u'-9999']
if 'end' not in p_item or p_item['end'] == []:
p_item['end'] = [u'2525']
if 'name' not in p_item or p_item['name'] == []:
p_item['name'] = ['NOT SPECIFIED']
PERIODS = {list_to_str(mp['name']).strip(): (
list_to_str(mp['start']),
list_to_str(mp['end']))
for mp in PERIOD_DICT}
for period in PERIODS:
if PERIODS[period][0].lstrip(
'-').isdigit() and PERIODS[period][1].lstrip('-').isdigit():
PERIODS[period] = (int(PERIODS[period][0]),
int(PERIODS[period][1]))
else:
PERIODS[period] = (
9999,
'ERROR - start and/or end of ' +
period +
' are not integers')
else:
periods = [p.strip() for p in options['cwp_period_map'].split(';')]
for p in periods:
p = p.split(',')
if len(p) == 3:
period = p[0].strip()
start = p[1].strip()
end = p[2].strip()
if start.lstrip(
'-').isdigit() and end.lstrip('-').isdigit():
PERIODS[period] = (int(start), int(end))
else:
PERIODS[period] = (
9999,
'ERROR - start and/or end of ' +
period +
' are not integers')
else:
PERIODS[p[0]] = (
9999, 'ERROR in period map - each item must contain 3 elements')
if options['cwp_period_tag'] and PERIODS:
if earliest_date == 9999: # i.e. no work date found
if options['cwp_use_muso_refdb'] and options['cwp_muso_dates']:
for composer_born in composer_born_list + arranger_born_list:
if composer_born and composer_born.isdigit():
birthdate = int(composer_born)
# productive age is taken as 20->death as per Muso
earliest_date = min(earliest_date, birthdate + 20)
for composer_died in composer_died_list + arranger_died_list:
if composer_died and composer_died.isdigit():
deathdate = int(composer_died)
latest_date = max(latest_date, deathdate)
else:
latest_date = datetime.now().year
# sort into start date order before writing tags
sorted_periods = collections.OrderedDict(
sorted(PERIODS.items(), key=lambda t: t[1]))
for period in sorted_periods:
if isinstance(
sorted_periods[period][1],
str) and 'ERROR' in sorted_periods[period][1]:
tm[options['cwp_period_tag']] = ''
append_tag(
release_id,
tm,
'001_errors:9',
'9. ' +
sorted_periods[period])
break
if earliest_date < 9999:
if sorted_periods[period][0] <= earliest_date <= sorted_periods[period][1]:
append_tag(
release_id,
tm,
options['cwp_period_tag'],
period)
if latest_date > -9999:
if sorted_periods[period][0] <= latest_date <= sorted_periods[period][1]:
append_tag(
release_id,
tm,
options['cwp_period_tag'],
period)
# generic tag mapping
sort_tags = options['cea_tag_sort']
if sort_tags:
tm['artists_sort'] = str_to_list(tm['~artists_sort'])
for i in range(0, 16):
tagline = options['cea_tag_' + str(i + 1)].split(",")
source_group = options['cea_source_' + str(i + 1)].split(",")
conditional = options['cea_cond_' + str(i + 1)]
for item, tagx in enumerate(tagline):
tag = tagx.strip()
sort = sort_suffix(tag)
if not conditional or tm[tag] == "":
for source_memberx in source_group:
source_member = source_memberx.strip()
sourceline = source_member.split("+")
if len(sourceline) > 1:
source = "\\"
for source_itemx in sourceline:
source_item = source_itemx.strip()
source_itema = source_itemx.lstrip()
write_log(
release_id, 'info', "Source_item: %s", source_item)
if "~cea_" + source_item in tm:
si = tm['~cea_' + source_item]
elif "~cwp_" + source_item in tm:
si = tm['~cwp_' + source_item]
elif source_item in tm:
si = tm[source_item]
elif len(source_itema) > 0 and source_itema[0] == "\\":
si = source_itema[1:]
else:
si = ""
if si != "" and source != "":
source = source + si
else:
source = ""
else:
source = sourceline[0]
no_names_source = re.sub('(_names)$', 's', source)
source_sort = sort_suffix(source)
write_log(
release_id,
'info',
"Tag mapping: Line: %s, Source: %s, Tag: %s, no_names_source: %s, sort: %s, item %s",
i +
1,
source,
tag,
no_names_source,
sort,
item)
if '~cea_' + source in tm or '~cwp_' + source in tm:
for prefix in ['~cea_', '~cwp_']:
if prefix + source in tm:
write_log(release_id, 'info', prefix)
append_tag(release_id, tm, tag,
tm[prefix + source], ['; '])
if sort_tags:
if prefix + no_names_source + source_sort in tm:
write_log(
release_id, 'info', prefix + " sort")
append_tag(release_id, tm, tag + sort,
tm[prefix + no_names_source + source_sort], ['; '])
elif source in tm or '~' + source in tm:
write_log(release_id, 'info', "Picard")
for p in ['', '~']:
if p + source in tm:
append_tag(release_id, tm, tag,
tm[p + source], ['; ', '/ '])
if sort_tags:
if "~" + source + source_sort in tm:
source = "~" + source
if source + source_sort in tm:
write_log(
release_id, 'info', "Picard sort")
append_tag(release_id, tm, tag + sort,
tm[source + source_sort], ['; ', '/ '])
elif len(source) > 0 and source[0] == "\\":
append_tag(release_id, tm, tag,
source[1:], ['; ', '/ '])
else:
pass
# write error messages to tags
if options['log_error'] and "~cea_error" in tm:
for error in str_to_list(tm['~cea_error']):
ecode = error[0]
append_tag(release_id, tm, '001_errors:' + ecode, error)
if options['log_warning'] and "~cea_warning" in tm:
for warning in str_to_list(tm['~cea_warning']):
wcode = warning[0]
append_tag(release_id, tm, '002_warnings:' + wcode, warning)
# delete unwanted tags
if not options['log_debug']:
if '~cea_works_complete' in tm:
del tm['~cea_works_complete']
if '~cea_artists_complete' in tm:
del tm['~cea_artists_complete']
del_list = []
for t in tm:
if 'ce_tag_cleared' in t:
del_list.append(t)
for t in del_list:
del tm[t]
# create hidden tags to flag differences
if options['ce_show_ui_tags'] and options['ce_ui_tags']:
for heading_name, tag_tuple in UI_TAGS: # UI_TAGS is already iterated in main routine, so no need for .items() method here
heading_tag = '~' + heading_name + '_VAL'
for tag in tag_tuple:
if tag[-5:] != '_DIFF':
append_tag(release_id, tm, heading_tag, tm[tag])
else:
tag = '~' + tag
mirror_tags = str_to_list((tm['~ce_mirror_tags']))
for mirror_tag in mirror_tags:
mt = interpret(mirror_tag)
st = str_to_list(mt)
(old_tag, new_tag) = tuple(st)
diff_name = old_tag.replace('OLD', 'DIFF')
if diff_name == tag and tm[old_tag] != tm[new_tag]:
tm[diff_name] = '*****'
append_tag(release_id, tm, heading_tag, '*****')
break
# if options over-write enabled, remove it after processing one album
options['ce_options_overwrite'] = False
config.setting['ce_options_overwrite'] = False
# so that options are not retained (in case of refresh with different
# options)
if '~ce_options' in tm:
del tm['~ce_options']
# remove any unwanted file tags
if '~ce_file' in tm and tm['~ce_file'] != "None":
music_file = tm['~ce_file']
orig_metadata = album.tagger.files[music_file].orig_metadata
if 'delete_tags' in options and options['delete_tags']:
warn = []
for delete_item in options['delete_tags']:
if delete_item not in tm: # keep the original for comparison if we have a new version
if delete_item in orig_metadata:
del orig_metadata[delete_item]
if delete_item != '002_warnings:7': # to avoid circularity!
warn.append(delete_item)
if warn and options['log_warning']:
append_tag(
release_id,
tm,
'002_warnings:7',
'7. Deleted tags: ' +
', '.join(warn))
write_log(
release_id,
'warning',
'Deleted tags: ' +
', '.join(warn))
def sort_suffix(tag):
"""To determine what sort suffix is appropriate for a given tag"""
if tag == "composer" or tag == "artist" or tag == "albumartist" or tag == "trackartist" or tag == "~cea_MB_artist":
sort = "sort"
else:
sort = "_sort"
return sort
def append_tag(release_id, tm, tag, source, separators=None):
"""
Update a tag
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param tm: track metadata
:param tag: tag to be appended to
:param source: item to append to tag
:param separators: characters which may be used to split string into a list
(any of the characters will be a split point)
:return: None. Action is on tm
"""
if not separators:
separators = []
if tag and tag != "":
if config.setting['log_info']:
write_log(
release_id,
'info',
'Appending source: %r to tag: %s (source is type %s) ...',
source,
tag,
type(source))
if tag in tm:
write_log(
release_id,
'info',
'... existing tag contents = %r',
tm[tag])
if source and len(source) > 0:
if isinstance(source, str):
if separators:
source = re.split('|'.join(separators), source)
else:
source = [source]
if not isinstance(source, list):
source = [source] # typically for dict items such as saved options
if all([isinstance(x, str) for x in source]): # only append if if source is a list of strings
if tag not in tm:
if tag == 'artists_sort':
# There is no artists_sort tag in Picard - just a
# hidden var ~artists_sort, so pick up those into the new tag
hidden = tm['~artists_sort']
if not isinstance(hidden, list):
if separators:
hidden = re.split(
'|'.join(separators), hidden)
for i, h in enumerate(hidden):
hidden[i] = h.strip()
else:
hidden = [hidden]
source = add_list_uniquely(source, hidden)
new_tag = True
else:
new_tag = False
for source_item in source:
if isinstance(source_item, str):
source_item = source_item.replace(u'\u2010', u'-')
source_item = source_item.replace(u'\u2011', u'-')
source_item = source_item.replace(u'\u2019', u"'")
source_item = source_item.replace(u'\u2018', u"'")
source_item = source_item.replace(u'\u201c', u'"')
source_item = source_item.replace(u'\u201d', u'"')
if new_tag:
tm[tag] = [source_item]
new_tag = False
else:
if not isinstance(tm[tag], list):
if separators:
tag_list = re.split(
'|'.join(separators), tm[tag])
for i, t in enumerate(tag_list):
tag_list[i] = t.strip()
else:
tag_list = [tm[tag]]
else:
tag_list = tm[tag]
if source_item not in tm[tag]:
tag_list.append(source_item)
tm[tag] = tag_list
# NB tag_list is used as metadata object will convert single-item lists to strings
else: # source items are not strings, so just replace
tm[tag] = source
def get_artist_credit(options, release_id, obj):
"""
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param options:
:param obj: an XmlNode
:return: a list of as-credited names
"""
name_credit_list = parse_data(release_id, obj, [], 'artist-credit')
credit_list = []
if name_credit_list:
for name_credits in name_credit_list:
for name_credit in name_credits:
credited_artist = parse_data(
release_id, name_credit, [], 'name')
if credited_artist:
name = parse_data(
release_id, name_credit, [], 'artist', 'name')
sort_name = parse_data(
release_id, name_credit, [], 'artist', 'sort-name')
credit_item = (credited_artist, name, sort_name)
credit_list.append(credit_item)
return credit_list
def get_aliases_and_credits(
self,
options,
release_id,
album,
obj,
lang,
credited):
"""
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:param self: This relates to the object in the class which called this function
:param options:
:param obj: an XmlNode
:param lang: The language selected in the Picard metadata options
:param credited: The options item to determine what as-credited names are being sought
:return: None. Sets self.artist_aliases and self.artist_credits[album]
"""
name_credit_list = parse_data(release_id, obj, [], 'artist-credit')
artist_list = parse_data(release_id, name_credit_list, [], 'artist')
for artist in artist_list:
sort_names = parse_data(release_id, artist, [], 'sort-name')
if sort_names:
aliases = parse_data(release_id, artist, [], 'aliases', 'locale:' +
lang, 'primary:True', 'name')
if aliases:
self.artist_aliases[sort_names[0]] = aliases[0]
if credited:
for name_credit in name_credit_list[0]:
credited_artist = parse_data(release_id, name_credit, [], 'name')
if credited_artist:
sort_name = parse_data(
release_id, name_credit, [], 'artist', 'sort-name')
if sort_name:
self.artist_credits[album][sort_name[0]
] = credited_artist[0]
def get_relation_credits(
self,
options,
release_id,
album,
obj,
lang,
credited):
"""
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param self:
:param options: UI options
:param album: current album
:param obj: Xmlnode
:param lang: language
:param credited: credited-as name
:return: None
Note that direct recording relationships will over-ride indirect ones (via work)
"""
rels = parse_data(release_id, obj, [], 'relations', 'target-type:work',
'work', 'relations', 'target-type:artist')
for artist in rels:
sort_names = parse_data(release_id, artist, [], 'artist', 'sort-name')
if sort_names:
credited_artists = parse_data(
release_id, artist, [], 'target-credit')
if credited_artists and credited_artists[0] != '' and credited:
self.artist_credits[album][sort_names[0]
] = credited_artists[0]
aliases = parse_data(
release_id,
artist,
[],
'artist',
'aliases',
'locale:' + lang,
'primary:True',
'name')
if aliases:
self.artist_aliases[sort_names[0]] = aliases[0]
rels2 = parse_data(release_id, obj, [], 'relations', 'target-type:artist')
for artist in rels2:
sort_names = parse_data(release_id, artist, [], 'artist', 'sort-name')
if sort_names:
credited_artists = parse_data(
release_id, artist, [], 'target-credit')
if credited_artists and credited_artists[0] != '' and credited:
self.artist_credits[album][sort_names[0]
] = credited_artists[0]
aliases = parse_data(
release_id,
artist,
[],
'artist',
'aliases',
'locale:' + lang,
'primary:True',
'name')
if aliases:
self.artist_aliases[sort_names[0]] = aliases[0]
def composer_last_names(self, release_id, tm, album):
"""
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param self:
:param tm:
:param album:
:return: None
Sets composer last names for album prefixing
"""
if '~cea_album_track_composer_lastnames' in tm:
if not isinstance(tm['~cea_album_track_composer_lastnames'], list):
atc_list = re.split(
'|'.join(
self.SEPARATORS),
tm['~cea_album_track_composer_lastnames'])
else:
atc_list = str_to_list(tm['~cea_album_track_composer_lastnames'])
for atc_item in atc_list:
composer_lastnames = atc_item.strip()
if '~length' in tm and tm['~length']:
track_length = time_to_secs(tm['~length'])
else:
track_length = 0
if album in self.album_artists:
if 'composer_lastnames' in self.album_artists[album]:
if composer_lastnames not in self.album_artists[album]['composer_lastnames']:
self.album_artists[album]['composer_lastnames'][composer_lastnames] = {
'length': track_length}
else:
self.album_artists[album]['composer_lastnames'][composer_lastnames]['length'] += track_length
else:
self.album_artists[album]['composer_lastnames'][composer_lastnames] = {
'length': track_length}
else:
self.album_artists[album]['composer_lastnames'][composer_lastnames] = {
'length': track_length}
else:
write_log(
release_id,
'warning',
"No _cea_album_track_composer_lastnames variable available for recording \"%s\".",
tm['title'])
if 'composer' in tm:
self.append_tag(
release_id,
release_id,
tm,
'~cea_warning',
'1. Composer for this track is not in album artists and will not be available to prefix album')
else:
self.append_tag(
release_id,
release_id,
tm,
'~cea_warning',
'1. No composer for this track, but checking parent work.')
def add_list_uniquely(list_to, list_from):
"""
Adds any items in list_from to list_to, if they are not already present
If either arg is a string, it will be converted to a list, e.g. 'abc' -> ['abc']
:param list_to:
:param list_from:
:return: appends only unique elements of list 2 to list 1
"""
#
if list_to and list_from:
if not isinstance(list_to, list):
list_to = str_to_list(list_to)
if not isinstance(list_from, list):
list_from = str_to_list(list_from)
for list_item in list_from:
if list_item not in list_to:
list_to.append(list_item)
else:
if list_from:
list_to = list_from
return list_to
def str_to_list(s):
"""
:param s:
:return: list from string using ; as separator
"""
if isinstance(s, list):
return s
if not isinstance(s, str):
try:
return list(s)
except TypeError:
return []
else:
if s == '':
return []
else:
return s.split('; ')
def list_to_str(l):
"""
:param l:
:return: string from list using ; as separator
"""
if not isinstance(l, list):
return l
else:
return '; '.join(l)
def interpret(tag):
"""
:param tag:
:return: safe form of eval(tag)
"""
if isinstance(tag, str):
try:
tag = tag.strip(' \n\t')
return ast.literal_eval(tag)
except (SyntaxError, ValueError):
return tag
else:
return tag
def time_to_secs(a):
"""
:param a: string x:x:x
:return: seconds
converts string times to seconds
"""
ax = a.split(':')
ax = ax[::-1]
t = 0
for i, x in enumerate(ax):
if x.isdigit():
t += int(x) * (60 ** i)
else:
return 0
return t
def seq_last_names(self, album):
"""
Sequences composer last names for album prefix by the total lengths of their tracks
:param self:
:param album:
:return:
"""
ln = []
if album in self.album_artists and 'composer_lastnames' in self.album_artists[album]:
for x in self.album_artists[album]['composer_lastnames']:
if 'length' in self.album_artists[album]['composer_lastnames'][x]:
ln.append([x, self.album_artists[album]
['composer_lastnames'][x]['length']])
else:
return []
ln = sorted(ln, key=lambda a: a[1])
ln = ln[::-1]
return [a[0] for a in ln]
def year(date):
"""
Return YYYY portion of date(s) in YYYY-MM-DD format (may be incomplete, string or list)
:param date:
:return: YYYY
"""
if isinstance(date, list):
year_list = [blank_if_none(d).split('-')[0] for d in date]
return year_list
else:
date_list = blank_if_none(date).split('-')
return [date_list[0]]
def blank_if_none(val):
"""
Make NoneTypes strings
:param val: str or None
:return: str
"""
if not val:
return ''
else:
return val
def strip_excess_punctuation(s):
"""
remove orphan punctuation, unmatched quotes and brackets
:param s: string
:return: string
"""
if s:
s_prev = ''
counter = 0
while s != s_prev:
if counter > 100:
break # safety valve
s_prev = s
s = s.replace(' ', ' ')
s = s.strip("&.-:;, ")
s = s.lstrip("!)]}")
s = s.rstrip("([{")
s = s.lstrip(u"\u2019") # Right single quote
s = s.lstrip(u"\u201D") # Right double quote
if s.count(u"\u201E") == 0: # u201E is lower double quote (German etc.)
s = s.rstrip(u"\u201C") # Left double quote - only strip if there is no German-style lower quote present
s = s.rstrip(u"\u2018") # Left single quote
if s.count('"') % 2 != 0:
s = s.strip('"')
if s.count("'") % 2 != 0:
s = s.strip("'")
if len(s) > 0 and s[0] == u"\u201C" and s.count(u"\u201D") == 0:
s = s.lstrip(u"\u201C")
if len(s) > 0 and s[-1] == u"\u201D" and s.count(u"\u201C") == 0 and s.count(u"\u201E") == 0: # only strip if there is no German-style lower quote present
s = s.rstrip(u"\u201D")
if len(s) > 0 and s[0] == u"\u2018" and s.count(u"\u2019") == 0:
s = s.lstrip(u"\u2018")
if len(s) > 0 and s[-1] == u"\u2019" and s.count(u"\u2018") == 0:
s = s.rstrip(u"\u2019")
if s:
if s.count("\"") == 1:
s = s.replace('"', '')
if s.count("\'") == 1:
s = s.replace(" '", " ")
# s = s.replace("' ", " ") # removed to prevent removal of genuine apostrophes
if "(" in s and ")" not in s:
s = s.replace("(", "")
if ")" in s and "(" not in s:
s = s.replace(")", "")
if "[" in s and "]" not in s:
s = s.replace("[", "")
if "]" in s and "[" not in s:
s = s.replace("]", "")
if "{" in s and "}" not in s:
s = s.replace("{", "")
if "}" in s and "{" not in s:
s = s.replace("}", "")
if s:
match_chars = [("(", ")"), ("[", "]"), ("{", "}")]
last = len(s) - 1
for char_pair in match_chars:
if char_pair[0] == s[0] and char_pair[1] == s[last]:
s = s.lstrip(char_pair[0]).rstrip(char_pair[1])
counter += 1
return s
#################
#################
# EXTRA ARTISTS #
#################
#################
class ExtraArtists():
# CONSTANTS
def __init__(self):
self.album_artists = collections.defaultdict(
lambda: collections.defaultdict(dict))
# collection of artists to be applied at album level
self.track_listing = collections.defaultdict(list)
# collection of tracks - format is {album: [track 1,
# track 2, ...]}
self.options = collections.defaultdict(dict)
# collection of Classical Extras options
self.globals = collections.defaultdict(dict)
# collection of global variables for this class
self.album_performers = collections.defaultdict(
lambda: collections.defaultdict(dict))
# collection of performers who have release relationships, not track
# relationships
self.album_instruments = collections.defaultdict(
lambda: collections.defaultdict(dict))
# collection of instruments which have release relationships, not track
# relationships
self.artist_aliases = {}
# collection of alias names - format is {sort_name: alias_name, ...}
self.artist_credits = collections.defaultdict(dict)
# collection of credited-as names - format is {album: {sort_name: credit_name,
# ...}, ...}
self.release_artists_sort = collections.defaultdict(list)
# collection of release artists - format is {album: [sort_name_1,
# sort_name_2, ...]}
self.lyricist_filled = collections.defaultdict(dict)
# Boolean for each track to indicate if lyricist has been found (don't
# want to add more from higher levels)
# NB this last one is for completeness - not actually used by
# ExtraArtists, but here to remove pep8 error
self.album_series_list = collections.defaultdict(dict)
# series relationships - format is {'name_list': series names, 'id_list': series ids, 'number_list': number within series}
def add_artist_info(
self,
album,
track_metadata,
trackXmlNode,
releaseXmlNode):
"""
Main routine run for each track of release
:param album: Current release
:param track_metadata: track metadata dictionary
:param trackXmlNode: Everything in the track node downwards
:param releaseXmlNode: Everything in the release node downwards (so includes all track nodes)
:return:
"""
release_id = track_metadata['musicbrainz_albumid']
if 'start' not in release_status[release_id]:
release_status[release_id]['start'] = datetime.now()
if 'lookups' not in release_status[release_id]:
release_status[release_id]['lookups'] = 0
release_status[release_id]['name'] = track_metadata['album']
release_status[release_id]['artists'] = True
if config.setting['log_debug'] or config.setting['log_info']:
write_log(
release_id,
'debug',
'STARTING ARTIST PROCESSING FOR ALBUM %s, DISC %s, TRACK %s',
track_metadata['album'],
track_metadata['discnumber'],
track_metadata['tracknumber'] +
' ' +
track_metadata['title'])
# write_log(release_id, 'info', 'trackXmlNode = %s', trackXmlNode) # NB can crash Picard
# write_log('info', 'releaseXmlNode = %s', releaseXmlNode) # NB can crash Picard
# Jump through hoops to get track object!!
track = album._new_tracks[-1]
tm = track.metadata
# OPTIONS - OVER-RIDE IF REQUIRED
if '~ce_options' not in tm:
if config.setting['log_debug'] or config.setting['log_info']:
write_log(release_id, 'debug', 'Artists gets track first...')
get_options(release_id, album, track)
options = interpret(tm['~ce_options'])
if not options:
if config.setting["log_error"]:
write_log(
release_id,
'error',
'Artists. Failure to read saved options for track %s. options = %s',
track,
tm['~ce_options'])
options = option_settings(config.setting)
self.options[track] = options
# CONSTANTS
self.ERROR = options["log_error"]
self.WARNING = options["log_warning"]
self.ORCHESTRAS = options["cea_orchestras"].split(',')
self.CHOIRS = options["cea_choirs"].split(',')
self.GROUPS = options["cea_groups"].split(',')
self.ENSEMBLE_TYPES = self.ORCHESTRAS + self.CHOIRS + self.GROUPS
self.SEPARATORS = ['; ', '/ ', ';', '/']
# continue?
if not options["classical_extra_artists"]:
return
# album_files is not used - this is just for logging
album_files = album.tagger.get_files_from_objects([album])
if options['log_info']:
write_log(
release_id,
'info',
'ALBUM FILENAMES for album %r = %s',
album,
album_files)
if not (
options["ce_no_run"] and (
not tm['~ce_file'] or tm['~ce_file'] == "None")):
# continue
write_log(
release_id,
'debug',
"ExtraArtists - add_artist_info")
if album not in self.track_listing or track not in self.track_listing[album]:
self.track_listing[album].append(track)
# fix odd hyphens in names for consistency
field_types = ['~albumartists', '~albumartists_sort']
for field_type in field_types:
if field_type in tm:
field = tm[field_type]
if isinstance(field, list):
for x, it in enumerate(field):
field[x] = it.replace(u'\u2010', u'-')
elif isinstance(field, str):
field = field.replace(u'\u2010', u'-')
else:
pass
tm[field_type] = field
# first time for this album (reloads each refresh)
if tm['discnumber'] == '1' and tm['tracknumber'] == '1':
# get artist aliases - these are cached so can be re-used across
# releases, but are reloaded with each refresh
get_aliases(self, release_id, album, options, releaseXmlNode)
# xml_type = 'release'
# get performers etc who are related at the release level
relation_list = parse_data(
release_id, releaseXmlNode, [], 'relations')
album_performerList = get_artists(
options, release_id, tm, relation_list, 'release')['artists']
self.album_performers[album] = album_performerList
album_instrumentList = get_artists(
options, release_id, tm, relation_list, 'release')['instruments']
self.album_instruments[album] = album_instrumentList
# get series information
self.album_series_list = get_series(
options, release_id, relation_list)
else:
if album in self.album_performers:
album_performerList = self.album_performers[album]
else:
album_performerList = []
if album in self.album_instruments and self.album_instruments[album]:
tm['~cea_instruments'] = self.album_instruments[album][0]
tm['~cea_instruments_credited'] = self.album_instruments[album][1]
tm['~cea_instruments_all'] = self.album_instruments[album][2]
# Should be OK to initialise these here as recording artists
# yet to be processed
# Fill release info not given by vanilla Picard
if self.album_series_list:
tm['series'] = self.album_series_list['name_list'] if 'name_list' in self.album_series_list else None
tm['musicbrainz_seriesid'] = self.album_series_list['id_list'] if 'id_list' in self.album_series_list else None
tm['series_number'] = self.album_series_list['number_list'] if 'number_list' in self.album_series_list else None
## TODO add label id too
recording_relation_list = parse_data(
release_id, trackXmlNode, [], 'recording', 'relations')
recording_series_list = get_series(
options, release_id, recording_relation_list)
write_log(
release_id,
'info',
'Recording_series_list = %s',
recording_series_list)
track_artist_list = parse_data(
release_id, trackXmlNode, [], 'artist-credit')
if track_artist_list:
track_artist = []
track_artistsort = []
track_artists = []
track_artists_sort = []
lang = get_preferred_artist_language(config)
# Set naming option
# Put naming style into preferential list
# naming as for vanilla Picard for track artists
if options['translate_artist_names'] and lang:
name_style = ['alias', 'sort']
# documentation indicates that processing should be as below,
# but processing above appears to reflect what vanilla Picard actually does
# if options['standardize_artists']:
# name_style = ['alias', 'sort']
# else:
# name_style = ['alias', 'credit', 'sort']
else:
if not options['standardize_artists']:
name_style = ['credit']
else:
name_style = []
write_log(
release_id,
'info',
'Priority order of naming style for track artists = %s',
name_style)
styled_artists = apply_artist_style(
options,
release_id,
lang,
track_artist_list,
name_style,
track_artist,
track_artistsort,
track_artists,
track_artists_sort)
tm['artists'] = styled_artists['artists']
tm['~artists_sort'] = styled_artists['artists_sort']
tm['artist'] = styled_artists['artist']
tm['artistsort'] = styled_artists['artistsort']
if 'recording' in trackXmlNode:
self.globals[track]['is_recording'] = True
write_log(release_id, 'debug', 'Getting recording details')
recording = trackXmlNode['recording']
if not isinstance(recording, list):
recording = [recording]
for record in recording:
rec_type = type(record)
write_log(release_id, 'info', 'rec-type = %s', rec_type)
write_log(release_id, 'info', record)
# Note that the lists below reflect https://musicbrainz.org/relationships/artist-recording
# Any changes to that DB structure will require changes
# here
# get recording artists data
recording_artist_list = parse_data(
release_id, record, [], 'artist-credit')
if recording_artist_list:
recording_artist = []
recording_artistsort = []
recording_artists = []
recording_artists_sort = []
lang = get_preferred_artist_language(config)
# Set naming option
# Put naming style into preferential list
# naming as for vanilla Picard for track artists (per
# documentation rather than actual?)
if options['cea_ra_trackartist']:
if options['translate_artist_names'] and lang:
if options['standardize_artists']:
name_style = ['alias', 'sort']
else:
name_style = ['alias', 'credit', 'sort']
else:
if not options['standardize_artists']:
name_style = ['credit']
else:
name_style = []
# naming as for performers in classical extras
elif options['cea_ra_performer']:
if options['cea_aliases']:
if options['cea_alias_overrides']:
name_style = ['alias', 'credit']
else:
name_style = ['credit', 'alias']
else:
name_style = ['credit']
else:
name_style = []
write_log(
release_id,
'info',
'Priority order of naming style for recording artists = %s',
name_style)
styled_artists = apply_artist_style(
options,
release_id,
lang,
recording_artist_list,
name_style,
recording_artist,
recording_artistsort,
recording_artists,
recording_artists_sort)
self.append_tag(
release_id,
tm,
'~cea_recording_artists',
styled_artists['artists'])
self.append_tag(
release_id,
tm,
'~cea_recording_artists_sort',
styled_artists['artists_sort'])
self.append_tag(
release_id,
tm,
'~cea_recording_artist',
styled_artists['artist'])
self.append_tag(
release_id,
tm,
'~cea_recording_artistsort',
styled_artists['artistsort'])
else:
tm['~cea_recording_artists'] = ''
tm['~cea_recording_artists_sort'] = ''
tm['~cea_recording_artist'] = ''
tm['~cea_recording_artistsort'] = ''
# use recording artist options
tm['~cea_MB_artist'] = str_to_list(tm['artist'])
tm['~cea_MB_artistsort'] = str_to_list(tm['artistsort'])
tm['~cea_MB_artists'] = str_to_list(tm['artists'])
tm['~cea_MB_artists_sort'] = str_to_list(tm['~artists_sort'])
if options['cea_ra_use']:
if options['cea_ra_replace_ta']:
if tm['~cea_recording_artist']:
tm['artist'] = str_to_list(tm['~cea_recording_artist'])
tm['artistsort'] = str_to_list(tm['~cea_recording_artistsort'])
tm['artists'] = str_to_list(tm['~cea_recording_artists'])
tm['~artists_sort'] = str_to_list(tm['~cea_recording_artists_sort'])
elif not options['cea_ra_noblank_ta']:
tm['artist'] = ''
tm['artistsort'] = ''
tm['artists'] = ''
tm['~artists_sort'] = ''
elif options['cea_ra_merge_ta']:
if tm['~cea_recording_artist']:
tm['artists'] = add_list_uniquely(
tm['artists'], tm['~cea_recording_artists'])
tm['~artists_sort'] = add_list_uniquely(
tm['~artists_sort'], tm['~cea_recording_artists_sort'])
if tm['artist'] != tm['~cea_recording_artist']:
tm['artist'] = tm['artist'] + \
' (' + tm['~cea_recording_artist'] + ')'
tm['artistsort'] = tm['artistsort'] + \
' (' + tm['~cea_recording_artistsort'] + ')'
# xml_type = 'recording'
relation_list = parse_data(
release_id, record, [], 'relations')
performerList = album_performerList + \
get_artists(options, release_id, tm, relation_list, 'recording')['artists']
# returns
# [(artist type, instrument or None, artist name, artist sort name, instrument sort, type sort)]
# where instrument sort places solo ahead of additional etc.
# and type sort applies a custom sequencing to the artist
# types
if performerList:
write_log(
release_id, 'info', "Performers: %s", performerList)
self.set_performer(
release_id, album, track, performerList, tm)
if not options['classical_work_parts']:
work_artist_list = parse_data(
release_id,
record,
[],
'relations',
'target-type:work',
'type:performance',
'work',
'relations',
'target-type:artist')
work_artists = get_artists(
options, release_id, tm, work_artist_list, 'work')['artists']
set_work_artists(
self, release_id, album, track, work_artists, tm, 0)
# otherwise composers etc. will be set in work parts
else:
self.globals[track]['is_recording'] = False
else:
tm['000_major_warning'] = "WARNING: Classical Extras not run for this track as no file present - " \
"deselect the option on the advanced tab to run. If there is a file, then try 'Refresh'."
if track_metadata['tracknumber'] == track_metadata['totaltracks'] and track_metadata[
'discnumber'] == track_metadata['totaldiscs']: # last track
self.process_album(release_id, album)
release_status[release_id]['artists-done'] = datetime.now()
close_log(release_id, 'artists')
# Checks for ensembles
def ensemble_type(self, performer):
"""
Returns ensemble types
:param performer:
:return:
"""
for ensemble_name in self.ORCHESTRAS:
ensemble = re.compile(
r'(.*)\b' +
ensemble_name +
r'\b(.*)',
re.IGNORECASE)
if ensemble.search(performer):
return 'Orchestra'
for ensemble_name in self.CHOIRS:
ensemble = re.compile(
r'(.*)\b' +
ensemble_name +
r'\b(.*)',
re.IGNORECASE)
if ensemble.search(performer):
return 'Choir'
for ensemble_name in self.GROUPS:
ensemble = re.compile(
r'(.*)\b' +
ensemble_name +
r'\b(.*)',
re.IGNORECASE)
if ensemble.search(performer):
return 'Group'
return False
def process_album(self, release_id, album):
"""
Perform final processing after all tracks read
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:return:
"""
write_log(
release_id,
'debug',
'ExtraArtists: Starting process_album')
# process lyrics tags
write_log(release_id, 'debug', 'Starting lyrics processing')
common = []
tmlyrics_dict = {}
tmlyrics_sort = []
options = {}
for track in self.track_listing[album]:
options = self.options[track]
if options['cea_split_lyrics'] and options['cea_lyrics_tag']:
tm = track.metadata
lyrics_tag = options['cea_lyrics_tag']
if tm[lyrics_tag]:
# turn text into word lists to speed processing
tmlyrics_dict[track] = tm[lyrics_tag].split()
if tmlyrics_dict:
tmlyrics_sort = sorted(
tmlyrics_dict.items(),
key=operator.itemgetter(1))
prev = None
first_track = None
unique_lyrics = []
ref_track = {}
for lyric_tuple in tmlyrics_sort: # tuple is (track, lyrics)
if lyric_tuple[1] != prev:
unique_lyrics.append(lyric_tuple[1])
first_track = lyric_tuple[0]
ref_track[lyric_tuple[0]] = first_track
prev = lyric_tuple[1]
common = turbo_lcs(
release_id,
unique_lyrics)
if common:
unique = []
for tup in tmlyrics_sort:
track = tup[0]
ref = ref_track[track]
if track == ref:
start = substart_finder(tup[1], common)
length = len(common)
end = min(start + length, len(tup[1]))
unique = tup[1][:start] + tup[1][end:]
options = self.options[track]
if options['cea_split_lyrics'] and options['cea_lyrics_tag']:
tm = track.metadata
if unique:
tm['~cea_track_lyrics'] = ' '.join(unique)
tm['~cea_album_lyrics'] = ' '.join(common)
if options['cea_album_lyrics']:
tm[options['cea_album_lyrics']] = tm['~cea_album_lyrics']
if unique and options['cea_track_lyrics']:
tm[options['cea_track_lyrics']] = tm['~cea_track_lyrics']
else:
for track in self.track_listing[album]:
options = self.options[track]
if options['cea_split_lyrics'] and options['cea_lyrics_tag']:
tm['~cea_track_lyrics'] = tm[options['cea_lyrics_tag']]
if options['cea_track_lyrics']:
tm[options['cea_track_lyrics']] = tm['~cea_track_lyrics']
write_log(release_id, 'debug', 'Ending lyrics processing')
for track in self.track_listing[album]:
self.write_metadata(release_id, options, album, track)
self.track_listing[album] = []
write_log(
release_id,
'info',
"FINISHED Classical Extra Artists. Album: %s",
album)
def write_metadata(self, release_id, options, album, track):
"""
Write the metadata for this track
:param release_id:
:param options:
:param album:
:param track:
:return:
"""
options = self.options[track]
tm = track.metadata
tm['~cea_version'] = PLUGIN_VERSION
# set inferred genres before any tags are blanked
if options['cwp_genres_infer']:
self.infer_genres(release_id, options, track, tm)
# album
if not options['classical_work_parts']:
if 'composer_lastnames' in self.album_artists[album]:
last_names = seq_last_names(self, album)
self.append_tag(
release_id,
tm,
'~cea_album_composer_lastnames',
last_names)
# otherwise this is done in the workparts class, which has all
# composer info
# process tag mapping
tm['~cea_artists_complete'] = "Y"
map_tags(options, release_id, album, tm)
# write out options and errors/warnings to tags
if options['cea_options_tag'] != "":
self.cea_options = collections.defaultdict(
lambda: collections.defaultdict(
lambda: collections.defaultdict(dict)))
for opt in plugin_options(
'artists') + plugin_options('tag') + plugin_options('picard'):
if 'name' in opt:
if 'value' in opt:
if options[opt['option']]:
self.cea_options['Classical Extras']['Artists options'][opt['name']] = opt['value']
else:
self.cea_options['Classical Extras']['Artists options'][opt['name']
] = options[opt['option']]
for opt in plugin_options('tag_detail'):
if opt['option'] != "":
name_list = opt['name'].split("_")
self.cea_options['Classical Extras']['Artists options'][name_list[0]
][name_list[1]] = options[opt['option']]
if options['ce_version_tag'] and options['ce_version_tag'] != "":
self.append_tag(release_id, tm, options['ce_version_tag'], str(
'Version ' + tm['~cea_version'] + ' of Classical Extras'))
if options['cea_options_tag'] and options['cea_options_tag'] != "":
self.append_tag(
release_id,
tm,
options['cea_options_tag'] +
':artists_options',
json.loads(
json.dumps(
self.cea_options)))
def infer_genres(self, release_id, options, track, tm):
"""
Infer a genre from the artist/instrument metadata
:param release_id:
:param options:
:param track:
:param tm: track metadata
:return:
"""
# Note that this is now mixed in with other sources of genres in def map_tags
# ~cea_work_type_if_classical is used for types that are specifically classical
# and is only applied in map_tags if the track is deemed to be
# classical
if (self.globals[track]['is_recording'] and options['classical_work_parts']
and '~artists_sort' in tm and 'composersort' in tm
and any(x in tm['~artists_sort'] for x in tm['composersort'])
and 'writer' not in tm
and not any(x in tm['~artists_sort'] for x in tm['~cea_performers_sort'])):
self.append_tag(
release_id, tm, '~cea_work_type', 'Classical')
if isinstance(tm['~cea_soloists'], str):
soloists = re.split(
'|'.join(
self.SEPARATORS),
tm['~cea_soloists'])
else:
soloists = tm['~cea_soloists']
if '~cea_vocalists' in tm:
if isinstance(tm['~cea_vocalists'], str):
vocalists = re.split(
'|'.join(
self.SEPARATORS),
tm['~cea_vocalists'])
else:
vocalists = tm['~cea_vocalists']
else:
vocalists = []
if '~cea_ensembles' in tm:
large = False
if 'performer:orchestra' in tm:
large = True
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Orchestral')
if '~cea_soloists' in tm:
if 'vocals' in tm['~cea_instruments_all']:
self.append_tag(
release_id, tm, '~cea_work_type', 'Vocal')
if len(soloists) == 1:
if soloists != vocalists:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Concerto')
else:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Aria')
elif len(soloists) == 2:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Duet')
if not vocalists:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Concerto')
elif len(soloists) == 3:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Trio')
elif len(soloists) == 4:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Quartet')
if 'performer:choir' in tm or 'performer:choir vocals' in tm:
large = True
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Choral')
self.append_tag(
release_id, tm, '~cea_work_type', 'Vocal')
else:
if large and 'soloists' in tm and tm['soloists'].count(
'vocals') > 1:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Opera')
if not large:
if '~cea_soloists' not in tm:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Chamber music')
else:
if vocalists:
self.append_tag(
release_id, tm, '~cea_work_type', 'Song')
self.append_tag(
release_id, tm, '~cea_work_type', 'Vocal')
else:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Chamber music')
else:
if len(soloists) == 1:
if vocalists != soloists:
self.append_tag(
release_id, tm, '~cea_work_type', 'Instrumental')
else:
self.append_tag(
release_id, tm, '~cea_work_type', 'Song')
self.append_tag(
release_id, tm, '~cea_work_type', 'Vocal')
elif len(soloists) == 2:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Duet')
elif len(soloists) == 3:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Trio')
elif len(soloists) == 4:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Quartet')
else:
if not vocalists:
self.append_tag(
release_id, tm, '~cea_work_type_if_classical', 'Chamber music')
else:
self.append_tag(
release_id, tm, '~cea_work_type', 'Song')
self.append_tag(
release_id, tm, '~cea_work_type', 'Vocal')
def append_tag(self, release_id, tm, tag, source):
"""
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param tm:
:param tag:
:param source:
:return:
"""
write_log(
release_id,
'info',
"Extra Artists - appending %s to %s",
source,
tag)
append_tag(release_id, tm, tag, source, self.SEPARATORS)
def set_performer(self, release_id, album, track, performerList, tm):
"""
Sets the performer-related tags
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:param track:
:param performerList: see below
:param tm:
:return:
"""
# performerList is in format [(artist_type, [instrument list],[name list],[sort_name list],
# instrument_sort, type_sort),(.....etc]
# Sorted by type_sort then sort name then instrument_sort
write_log(release_id, 'debug', "Extra Artists - set_performer")
write_log(release_id, 'info', "Performer list is:")
write_log(release_id, 'info', performerList)
options = self.options[track]
# tag strings are a tuple (Picard tag, cea tag, Picard sort tag, cea
# sort tag)
tag_strings = const.tag_strings('~cea')
# insertions lists artist types where names in the main Picard tags may be updated for annotations
# (not for performer types as Picard will write performer:inst as Performer name (inst) )
insertions = const.INSERTIONS
# First remove all existing performer tags
del_list = []
for meta in tm:
if 'performer' in meta:
del_list.append(meta)
for del_item in del_list:
del tm[del_item]
last_artist = []
last_inst_list = []
last_instrument = None
artist_inst = []
artist_inst_list = {}
for performer in performerList:
artist_type = performer[0]
if artist_type not in tag_strings:
return None
if artist_type in ['instrument', 'vocal', 'performing orchestra']:
if performer[1]:
inst_list = performer[1]
attrib_list = []
for attrib in ['solo', 'guest', 'additional']:
if attrib in inst_list:
inst_list.remove(attrib)
attrib_list.append(attrib)
attribs = " ".join(attrib_list)
instrument = ", ".join(inst_list)
if not options['cea_no_solo'] and attrib_list:
instrument = attribs + " " + instrument
if performer[3] == last_artist:
if instrument != last_instrument:
artist_inst.append(instrument)
else:
if inst_list == last_inst_list:
write_log(
release_id, 'warning', 'Duplicated performer information for %s'
' (may be in Release Relationship as well as Track Relationship).'
' Duplicates have been ignored.', performer[3])
if self.WARNING:
self.append_tag(
release_id,
tm,
'~cea_warning',
'2. Duplicated performer information for "' +
'; '.join(
performer[3]) +
'" (may be in Release Relationship as well as Track Relationship).'
' Duplicates have been ignored.')
else:
artist_inst = [instrument]
last_artist = performer[3]
last_inst_list = inst_list
last_instrument = instrument
instrument = ", ".join(artist_inst)
else:
instrument = None
if artist_type == 'performing orchestra':
instrument = 'orchestra'
artist_inst_list[tuple(performer[3])] = instrument
for performer in performerList:
artist_type = performer[0]
if artist_type not in tag_strings:
return None
performing_artist = False if artist_type in [
'arranger', 'instrument arranger', 'orchestrator', 'vocal arranger'] else True
if True and artist_type in [
'instrument',
'vocal',
'performing orchestra']: # There may be an option here (to replace 'True')
# Currently groups instruments by artist - alternative has been
# tested if required
instrument = artist_inst_list[tuple(performer[3])]
else:
if performer[1]:
inst_list = performer[1]
if options['cea_no_solo']:
for attrib in ['solo', 'guest', 'additional']:
if attrib in inst_list:
inst_list.remove(attrib)
instrument = " ".join(inst_list)
else:
instrument = None
if artist_type == 'performing orchestra':
instrument = 'orchestra'
sub_strings = {'instrument': instrument,
'vocal': instrument # ,
# 'instrument arranger': instrument,
# 'vocal arranger': instrument
}
for typ in ['concertmaster']:
if options['cea_' + typ] and options['cea_arrangers']:
sub_strings[typ] = ':' + options['cea_' + typ]
if options['cea_arranger']:
if instrument:
arr_inst = options['cea_arranger'] + ' ' + instrument
else:
arr_inst = options['cea_arranger']
else:
arr_inst = instrument
annotations = {'instrument': instrument,
'vocal': instrument,
'performing orchestra': instrument,
'chorus master': options['cea_chorusmaster'],
'concertmaster': options['cea_concertmaster'],
'arranger': options['cea_arranger'],
'instrument arranger': arr_inst,
'orchestrator': options['cea_orchestrator'],
'vocal arranger': arr_inst}
tag = tag_strings[artist_type][0]
cea_tag = tag_strings[artist_type][1]
sort_tag = tag_strings[artist_type][2]
cea_sort_tag = tag_strings[artist_type][3]
cea_names_tag = cea_tag[:-1] + '_names'
cea_instrumented_tag = cea_names_tag + '_instrumented'
if artist_type in sub_strings:
if sub_strings[artist_type]:
tag += sub_strings[artist_type]
else:
write_log(
release_id,
'warning',
'No instrument/sub-key available for artist_type %s. Performer = %s. Track is %s',
artist_type,
performer[2],
track)
if tag:
if '~ce_tag_cleared_' + \
tag not in tm or not tm['~ce_tag_cleared_' + tag] == "Y":
if tag in tm:
write_log(release_id, 'info', 'delete tag %s', tag)
del tm[tag]
tm['~ce_tag_cleared_' + tag] = "Y"
if sort_tag:
if '~ce_tag_cleared_' + \
sort_tag not in tm or not tm['~ce_tag_cleared_' + sort_tag] == "Y":
if sort_tag in tm:
del tm[sort_tag]
tm['~ce_tag_cleared_' + sort_tag] = "Y"
name_list = performer[2]
for ind, name in enumerate(name_list):
performer_type = ''
sort_name = performer[3][ind]
no_credit = True
# change name to as-credited
if (performing_artist and options['cea_performer_credited'] or
not performing_artist and options['cea_composer_credited']):
if sort_name in self.artist_credits[album]:
no_credit = False
name = self.artist_credits[album][sort_name]
# over-ride with aliases and use standard MB name (not
# as-credited) if no alias
if (options['cea_aliases'] or not performing_artist and options['cea_aliases_composer']) and (
no_credit or options['cea_alias_overrides']):
if sort_name in self.artist_aliases:
name = self.artist_aliases[sort_name]
# fix cyrillic names if not already fixed
if options['cea_cyrillic']:
if not only_roman_chars(name):
name = remove_middle(unsort(sort_name))
# Only remove middle name where the existing
# performer is in non-latin script
annotated_name = name
if instrument:
instrumented_name = name + ' (' + instrument + ')'
else:
instrumented_name = name
# add annotations and write performer tags
if artist_type in annotations:
if annotations[artist_type]:
annotated_name += ' (' + annotations[artist_type] + ')'
else:
write_log(
release_id,
'warning',
'No annotation (instrument) available for artist_type %s.'
' Performer = %s. Track is %s',
artist_type,
performer[2],
track)
if artist_type in insertions and options['cea_arrangers']:
self.append_tag(release_id, tm, tag, annotated_name)
else:
if options['cea_arrangers'] or artist_type == tag:
self.append_tag(release_id, tm, tag, name)
if options['cea_arrangers'] or artist_type == tag:
if sort_tag:
self.append_tag(release_id, tm, sort_tag, sort_name)
if options['cea_tag_sort'] and '~' in sort_tag:
explicit_sort_tag = sort_tag.replace('~', '')
self.append_tag(
release_id, tm, explicit_sort_tag, sort_name)
self.append_tag(release_id, tm, cea_tag, annotated_name)
self.append_tag(release_id, tm, cea_names_tag, name)
if instrumented_name != name:
self.append_tag(
release_id,
tm,
cea_instrumented_tag,
instrumented_name)
if cea_sort_tag:
self.append_tag(release_id, tm, cea_sort_tag, sort_name)
# differentiate soloists etc and write related tags
if artist_type == 'performing orchestra' or (
instrument and instrument in self.ENSEMBLE_TYPES) or self.ensemble_type(name):
performer_type = 'ensembles'
self.append_tag(
release_id, tm, '~cea_ensembles', instrumented_name)
self.append_tag(
release_id, tm, '~cea_ensemble_names', name)
self.append_tag(
release_id, tm, '~cea_ensembles_sort', sort_name)
elif artist_type in ['performer', 'instrument', 'vocal']:
performer_type = 'soloists'
self.append_tag(
release_id, tm, '~cea_soloists', instrumented_name)
self.append_tag(release_id, tm, '~cea_soloist_names', name)
self.append_tag(
release_id, tm, '~cea_soloists_sort', sort_name)
if artist_type == "vocal":
self.append_tag(
release_id, tm, '~cea_vocalists', instrumented_name)
self.append_tag(
release_id, tm, '~cea_vocalist_names', name)
self.append_tag(
release_id, tm, '~cea_vocalists_sort', sort_name)
elif instrument:
self.append_tag(
release_id, tm, '~cea_instrumentalists', instrumented_name)
self.append_tag(
release_id, tm, '~cea_instrumentalist_names', name)
self.append_tag(
release_id, tm, '~cea_instrumentalists_sort', sort_name)
else:
self.append_tag(
release_id, tm, '~cea_other_soloists', instrumented_name)
self.append_tag(
release_id, tm, '~cea_other_soloist_names', name)
self.append_tag(
release_id, tm, '~cea_other_soloists_sort', sort_name)
# set album artists
if performer_type or artist_type == 'conductor':
cea_album_tag = cea_tag.replace(
'cea', 'cea_album').replace(
'performers', performer_type)
cea_album_sort_tag = cea_sort_tag.replace(
'cea', 'cea_album').replace(
'performers', performer_type)
if stripsir(name) in tm['~albumartists'] or stripsir(
sort_name) in tm['~albumartists_sort']:
self.append_tag(release_id, tm, cea_album_tag, name)
self.append_tag(
release_id, tm, cea_album_sort_tag, sort_name)
else:
if performer_type:
self.append_tag(
release_id, tm, '~cea_support_performers', instrumented_name)
self.append_tag(
release_id, tm, '~cea_support_performer_names', name)
self.append_tag(
release_id, tm, '~cea_support_performers_sort', sort_name)
##############
##############
# WORK PARTS #
##############
##############
class PartLevels():
# QUEUE-HANDLING
class WorksQueue(LockableObject):
"""Object for managing the queue of lookups"""
def __init__(self):
LockableObject.__init__(self)
self.queue = {}
def __contains__(self, name):
return name in self.queue
def __iter__(self):
return self.queue.__iter__()
def __getitem__(self, name):
self.lock_for_read()
value = self.queue[name] if name in self.queue else None
self.unlock()
return value
def __setitem__(self, name, value):
self.lock_for_write()
self.queue[name] = value
self.unlock()
def append(self, name, value):
self.lock_for_write()
if name in self.queue:
self.queue[name].append(value)
value = False
else:
self.queue[name] = [value]
value = True
self.unlock()
return value
def remove(self, name):
self.lock_for_write()
value = None
if name in self.queue:
value = self.queue[name]
del self.queue[name]
self.unlock()
return value
# INITIALISATION
def __init__(self):
self.works_cache = {}
# maintains list of parent of each workid, or None if no parent found,
# so that XML lookup need only executed if no existing record
self.partof = collections.defaultdict(dict)
# the inverse of the above (immediate children of each parent)
# but note that this is specific to the album as children may vary between albums
# so format is {album1{parent1: child1, parent2:, child2},
# album2{....}}
self.works_queue = self.WorksQueue()
# lookup queue - holds track/album pairs for each queued workid (may be
# more than one pair per id, especially for higher-level parts)
self.parts = collections.defaultdict(
lambda: collections.defaultdict(dict))
# metadata collection for all parts - structure is {workid: {name: ,
# parent: , (track,album): {part_levels}}, etc}
self.top_works = collections.defaultdict(dict)
# metadata collection for top-level works for (track, album) -
# structure is {(track, album): {workId: }, etc}
self.trackback = collections.defaultdict(
lambda: collections.defaultdict(dict))
# hierarchical iterative work structure - {album: {id: , children:{id:
# , children{}, id: etc}, id: etc} }
self.child_listing = collections.defaultdict(list)
# contains list of workIds which are descendants of a given workId, to
# prevent recursion when adding new ids
self.work_listing = collections.defaultdict(list)
# contains list of workIds for each album
self.top = collections.defaultdict(list)
# self.top[album] = list of work Ids which are top-level works in album
self.options = collections.defaultdict(dict)
# active Classical Extras options for current track
self.synonyms = collections.defaultdict(dict)
# active synonym options for current track
self.replacements = collections.defaultdict(dict)
# active synonym options for current track
self.file_works = collections.defaultdict(list)
# list of works derived from SongKong-style file tags
# structure is {(album, track): [{workid: , name: }, {workid: ....}}
self.album_artists = collections.defaultdict(
lambda: collections.defaultdict(dict))
# collection of artists to be applied at album level
self.artist_aliases = {}
# collection of alias names - format is {sort_name: alias_name, ...}
self.artist_credits = collections.defaultdict(dict)
# collection of credited-as names - format is {album: {sort_name: credit_name,
# ...}, ...}
self.release_artists_sort = collections.defaultdict(list)
# collection of release artists - format is {album: [sort_name_1,
# sort_name_2, ...]}
self.lyricist_filled = collections.defaultdict(dict)
# Boolean for each track to indicate if lyricist has been found (don't
# want to add more from higher levels)
self.orphan_tracks = collections.defaultdict(list)
# To keep a list for each album of tracks which do not have works -
# format is {album: [track1, track2, ...], etc}
self.tracks = collections.defaultdict(
lambda: collections.defaultdict(dict))
# To keep a list of all tracks for the album - format is {album:
# {track1: {movement-group: movementgroup, movement-number: movementnumber},
# track2: {}, ..., etc}, album2: etc}
########################################
# SECTION 1 - Initial track processing #
########################################
def add_work_info(
self,
album,
track_metadata,
trackXmlNode,
releaseXmlNode):
"""
Main Routine - run for each track
:param album:
:param track_metadata:
:param trackXmlNode:
:param releaseXmlNode:
:return:
"""
release_id = track_metadata['musicbrainz_albumid']
if 'start' not in release_status[release_id]:
release_status[release_id]['start'] = datetime.now()
if 'lookups' not in release_status[release_id]:
release_status[release_id]['lookups'] = 0
release_status[release_id]['name'] = track_metadata['album']
release_status[release_id]['works'] = True
if config.setting['log_debug'] or config.setting['log_info']:
write_log(
release_id,
'debug',
'STARTING WORKS PROCESSING FOR ALBUM %s, DISC %s, TRACK %s',
track_metadata['album'],
track_metadata['discnumber'],
track_metadata['tracknumber'] +
' ' +
track_metadata['title'])
# clear the cache if required (if this is not done, then queue count may get out of sync)
# Jump through hoops to get track object!!
track = album._new_tracks[-1]
tm = track.metadata
if config.setting['log_debug'] or config.setting['log_info']:
write_log(
release_id,
'debug',
'Cache setting for track %s is %s',
track,
config.setting['use_cache'])
# OPTIONS - OVER-RIDE IF REQUIRED
if '~ce_options' not in tm:
if config.setting['log_debug'] or config.setting['log_info']:
write_log(release_id, 'debug', 'Workparts gets track first...')
get_options(release_id, album, track)
options = interpret(tm['~ce_options'])
if not options:
if config.setting['log_error']:
write_log(
release_id,
'error',
'Work Parts. Failure to read saved options for track %s. options = %s',
track,
tm['~ce_options'])
options = option_settings(config.setting)
self.options[track] = options
# CONSTANTS
write_log(release_id, 'basic', 'Options: %s' ,options)
self.ERROR = options["log_error"]
self.WARNING = options["log_warning"]
self.SEPARATORS = ['; ']
self.EQ = "EQ_TO_BE_REVERSED" # phrase to indicate that a synonym has been used
self.get_sk_tags(release_id, album, track, tm, options)
self.synonyms[track] = self.get_text_tuples(
release_id, track, 'synonyms') # a list of tuples
self.replacements[track] = self.get_text_tuples(
release_id, track, 'replacements') # a list of tuples
# Continue?
if not options["classical_work_parts"]:
return
# OPTION-DEPENDENT CONSTANTS:
# Maximum number of XML- lookup retries if error returned from server
self.MAX_RETRIES = options["cwp_retries"]
self.USE_CACHE = options["use_cache"]
if options["cwp_partial"] and options["cwp_partial_text"] and options["cwp_level0_works"]:
options["cwp_removewords_p"] = options["cwp_removewords"] + \
", " + options["cwp_partial_text"] + ' '
else:
options["cwp_removewords_p"] = options["cwp_removewords"]
# Explanation:
# If "Partial" is selected then the level 0 work name will have PARTIAL_TEXT appended to it.
# If a recording is split across several tracks then each sub-part (quasi-movement) will have the same name
# (with the PARTIAL_TEXT added). If level 0 is used to source work names then the level 1 work name will be
# changed to be this repeated name and will therefore also include PARTIAL_TEXT.
# So we need to add PARTIAL_TEXT to the prefixes list to ensure it is excluded from the level 1 work name.
#
write_log(
release_id,
'debug',
"PartLevels - LOAD NEW TRACK: :%s",
track)
# write_log(release_id, 'info', "trackXmlNode:") # warning - may break Picard
# first time for this album (reloads each refresh)
if tm['discnumber'] == '1' and tm['tracknumber'] == '1':
# get artist aliases - these are cached so can be re-used across
# releases, but are reloaded with each refresh
get_aliases(self, release_id, album, options, releaseXmlNode)
# fix titles which include composer name
composersort =[]
if 'compposersort' in tm:
composersort = str_to_list(['composersort'])
composerlastnames = []
for composer in composersort:
lname = re.compile(r'(.*),')
match = lname.search(composer)
if match:
composerlastnames.append(match.group(1))
else:
composerlastnames.append(composer)
title = track_metadata['title']
colons = title.count(":")
if colons > 0:
title_split = title.split(': ', 1)
test = title_split[0]
if test in composerlastnames:
track_metadata['~cwp_title'] = title_split[1]
# now process works
write_log(
release_id,
'info',
'PartLevels - add_work_info - metadata load = %r',
track_metadata)
workIds = []
if 'musicbrainz_workid' in tm:
workIds = str_to_list(tm['musicbrainz_workid'])
if workIds and not (options["ce_no_run"] and (
not tm['~ce_file'] or tm['~ce_file'] == "None")):
self.build_work_info(release_id, options, trackXmlNode, album, track, track_metadata, workIds)
else: # no work relation
write_log(
release_id,
'warning',
"WARNING - no works for this track: \"%s\"",
title)
self.append_tag(
release_id,
track_metadata,
'~cwp_warning',
'3. No works for this track')
if album in self.orphan_tracks:
if track not in self.orphan_tracks[album]:
self.orphan_tracks[album].append(track)
else:
self.orphan_tracks[album] = [track]
# Don't publish metadata yet until all album is processed
# last track
write_log(
release_id,
'debug',
'Check for last track. Requests = %s, Tracknumber = %s, Totaltracks = %s,'
' Discnumber = %s, Totaldiscs = %s',
album._requests,
track_metadata['tracknumber'],
track_metadata['totaltracks'],
track_metadata['discnumber'],
track_metadata['totaldiscs'])
if album._requests == 0 and track_metadata['tracknumber'] == track_metadata[
'totaltracks'] and track_metadata['discnumber'] == track_metadata['totaldiscs']:
self.process_album(release_id, album)
release_status[release_id]['works-done'] = datetime.now()
close_log(release_id, 'works')
def build_work_info(self, release_id, options, trackXmlNode, album, track, track_metadata, workIds):
"""
Construct the work metadata, taking into account partial recordings and medleys
:param release_id:
:param options:
:param trackXmlNode: JSON returned by the webservice
:param album:
:param track:
:param track_metadata:
:param workIds: work ids for this track
:return:
"""
work_list_info = []
keyed_workIds = {}
for i, workId in enumerate(workIds):
# sort by ordering_key, if any
match_tree = [
'recording',
'relations',
'target-type:work',
'work',
'id:' + workId]
rels = parse_data(release_id, trackXmlNode, [], *match_tree)
# for recordings which are ordered within track:-
match_tree_1 = [
'ordering-key']
# for recordings of works which are ordered as part of parent
# (may be duplicated by top-down check later):-
match_tree_2 = [
'relations',
'target-type:work',
'type:parts',
'direction:backward',
'ordering-key']
parse_result = parse_data(release_id,
rels,
[],
*match_tree_1) + parse_data(release_id,
rels,
[],
*match_tree_2)
write_log(
release_id,
'info',
'multi-works - ordering key: %s',
parse_result)
if parse_result:
if isinstance(parse_result[0], int):
key = parse_result[0]
elif isinstance(parse_result[0], str) and parse_result[0].isdigit():
key = int(parse_result[0])
else:
key = 100 + i
else:
key = 100 + i
keyed_workIds[key] = workId
partial = False
for key in sorted(keyed_workIds):
workId = keyed_workIds[key]
work_rels = parse_data(
release_id,
trackXmlNode,
[],
'recording',
'relations',
'target-type:work',
'work.id:' + workId)
write_log(release_id, 'info', 'work_rels: %s', work_rels)
work_attributes = parse_data(
release_id, work_rels, [], 'attributes')[0]
write_log(
release_id,
'info',
'work_attributes: %s',
work_attributes)
work_titles = parse_data(
release_id, work_rels, [], 'work', 'title')
work_list_info_item = {
'id': workId,
'attributes': work_attributes,
'titles': work_titles}
work_list_info.append(work_list_info_item)
work = []
for title in work_titles:
work.append(title)
if options['cwp_partial']:
# treat the recording as work level 0 and the work of which it
# is a partial recording as work level 1
if 'partial' in work_attributes:
partial = True
parentId = workId
workId = track_metadata['musicbrainz_recordingid']
works = []
for w in work:
partwork = w
works.append(partwork)
write_log(
release_id,
'info',
"Id %s is PARTIAL RECORDING OF id: %s, name: %s",
workId,
parentId,
work)
work_list_info_item = {
'id': workId,
'attributes': [],
'titles': works,
'parent': parentId}
work_list_info.append(work_list_info_item)
write_log(
release_id,
'info',
'work_list_info: %s',
work_list_info)
# we now have a list of items, where the id of each is a work id for the track or
# (multiple instances of) the recording id (for partial works)
# we need to turn this into a usable hierarchy - i.e. just one item
workId_list = []
work_list = []
parent_list = []
attribute_list = []
workId_list_p = []
work_list_p = []
attribute_list_p = []
for w in work_list_info:
if 'partial' not in w['attributes'] or not options[
'cwp_partial']: # just do the bottom-level 'works' first
workId_list.append(w['id'])
work_list += w['titles']
attribute_list += w['attributes']
if 'parent' in w:
if w['parent'] not in parent_list: # avoid duplicating parents!
parent_list.append(w['parent'])
else:
workId_list_p.append(w['id'])
work_list_p += w['titles']
attribute_list_p += w['attributes']
# de-duplicate work names
# list(set()) won't work as need to retain order
work_list = list(collections.OrderedDict.fromkeys(work_list))
work_list_p = list(collections.OrderedDict.fromkeys(work_list_p))
workId_tuple = tuple(workId_list)
workId_tuple_p = tuple(workId_list_p)
if workId_tuple not in self.work_listing[album]:
self.work_listing[album].append(workId_tuple)
if workId_tuple not in self.parts or not self.USE_CACHE:
self.parts[workId_tuple]['name'] = work_list
if parent_list:
if workId_tuple in self.works_cache:
self.works_cache[workId_tuple] += parent_list
self.parts[workId_tuple]['parent'] += parent_list
else:
self.works_cache[workId_tuple] = parent_list
self.parts[workId_tuple]['parent'] = parent_list
self.parts[workId_tuple_p]['name'] = work_list_p
if workId_tuple_p not in self.work_listing[album]:
self.work_listing[album].append(workId_tuple_p)
if 'medley' in attribute_list_p:
self.parts[workId_tuple_p]['medley'] = True
if 'medley' in attribute_list:
self.parts[workId_tuple]['medley'] = True
if partial:
self.parts[workId_tuple]['partial'] = True
self.trackback[album][workId_tuple]['id'] = workId_list
if 'meta' in self.trackback[album][workId_tuple]:
if (track,
album) not in self.trackback[album][workId_tuple]['meta']:
self.trackback[album][workId_tuple]['meta'].append(
(track, album))
else:
self.trackback[album][workId_tuple]['meta'] = [(track, album)]
write_log(
release_id,
'info',
"Trackback for %s is %s. Partial = %s",
track,
self.trackback[album][workId_tuple],
partial)
if workId_tuple in self.works_cache and (
self.USE_CACHE or partial):
write_log(
release_id,
'debug',
"GETTING WORK METADATA FROM CACHE, for work %s",
workId_tuple)
if workId_tuple not in self.work_listing[album]:
self.work_listing[album].append(workId_tuple)
not_in_cache = self.check_cache(
track_metadata, album, track, workId_tuple, [])
else:
if partial:
not_in_cache = [workId_tuple_p]
else:
not_in_cache = [workId_tuple]
for workId_tuple in not_in_cache:
if not self.USE_CACHE:
if workId_tuple in self.works_cache:
del self.works_cache[workId_tuple]
self.work_not_in_cache(release_id, album, track, workId_tuple)
def get_sk_tags(self, release_id, album, track, tm, options):
"""
Get file tags which are consistent with SongKong's metadata usage
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:param track:
:param tm:
:param options:
:return:
"""
if options["cwp_use_sk"]:
if '~ce_file' in tm and interpret(tm['~ce_file']):
music_file = tm['~ce_file']
orig_metadata = album.tagger.files[music_file].orig_metadata
if 'musicbrainz_work_composition_id' in orig_metadata and 'musicbrainz_workid' in orig_metadata:
if 'musicbrainz_work_composition' in orig_metadata:
if 'musicbrainz_work' in orig_metadata:
if orig_metadata['musicbrainz_work_composition_id'] == orig_metadata[
'musicbrainz_workid'] \
and orig_metadata['musicbrainz_work_composition'] != orig_metadata[
'musicbrainz_work']:
# Picard may have overwritten SongKong tag (top
# work id) with bottom work id
write_log(
release_id,
'warning',
'File tag musicbrainz_workid incorrect? id = %s. Sourcing from MB',
orig_metadata['musicbrainz_workid'])
if self.WARNING:
self.append_tag(
release_id,
tm,
'~cwp_warning',
'4. File tag musicbrainz_workid incorrect? id = ' +
orig_metadata['musicbrainz_workid'] +
'. Sourcing from MB')
return None
write_log(
release_id,
'info',
'Read from file tag: musicbrainz_work_composition_id: %s',
orig_metadata['musicbrainz_work_composition_id'])
self.file_works[(album, track)].append({
'workid': orig_metadata['musicbrainz_work_composition_id'].split('; '),
'name': orig_metadata['musicbrainz_work_composition']})
else:
wid = orig_metadata['musicbrainz_work_composition_id']
write_log(
release_id,
'error',
"No matching work name for id tag %s",
wid)
if self.ERROR:
self.append_tag(
release_id,
tm,
'~cwp_error',
'2. No matching work name for id tag ' +
wid)
return None
n = 1
while 'musicbrainz_work_part_level' + \
str(n) + '_id' in orig_metadata:
if 'musicbrainz_work_part_level' + \
str(n) in orig_metadata:
self.file_works[(album, track)].append({
'workid': orig_metadata[
'musicbrainz_work_part_level' + str(n) + '_id'].split('; '),
'name': orig_metadata['musicbrainz_work_part_level' + str(n)]})
n += 1
else:
wid = orig_metadata['musicbrainz_work_part_level' +
str(n) + '_id']
write_log(
release_id, 'error', "No matching work name for id tag %s", wid)
if self.ERROR:
self.append_tag(
release_id,
tm,
'~cwp_error',
'2. No matching work name for id tag ' +
wid)
break
if orig_metadata['musicbrainz_work_composition_id'] != orig_metadata[
'musicbrainz_workid']:
if 'musicbrainz_work' in orig_metadata:
self.file_works[(album, track)].append({
'workid': orig_metadata['musicbrainz_workid'].split('; '),
'name': orig_metadata['musicbrainz_work']})
else:
wid = orig_metadata['musicbrainz_workid']
write_log(
release_id, 'error', "No matching work name for id tag %s", wid)
if self.ERROR:
self.append_tag(
release_id,
tm,
'~cwp_error',
'2. No matching work name for id tag ' +
wid)
return None
file_work_levels = len(self.file_works[(album, track)])
write_log(release_id,
'debug',
'Loaded works from file tags for track %s. Works: %s: ',
track,
self.file_works[(album,
track)])
for i, work in enumerate(self.file_works[(album, track)]):
workId = tuple(work['workid'])
if workId not in self.works_cache: # Use cache in preference to file tags
if workId not in self.work_listing[album]:
self.work_listing[album].append(workId)
self.parts[workId]['name'] = [work['name']]
parentId = None
parent = ''
if i < file_work_levels - 1:
parentId = self.file_works[(
album, track)][i + 1]['workid']
parent = self.file_works[(
album, track)][i + 1]['name']
if parentId:
self.works_cache[workId] = parentId
self.parts[workId]['parent'] = parentId
self.parts[tuple(parentId)]['name'] = [parent]
else:
# so we remember we looked it up and found none
self.parts[workId]['no_parent'] = True
self.top_works[(track, album)
]['workId'] = workId
if workId not in self.top[album]:
self.top[album].append(workId)
def check_cache(self, tm, album, track, workId_tuple, not_in_cache):
"""
Recursive loop to get cached works
:param tm:
:param album:
:param track:
:param workId_tuple:
:param not_in_cache:
:return:
"""
parentId_tuple = tuple(self.works_cache[workId_tuple])
if parentId_tuple not in self.work_listing[album]:
self.work_listing[album].append(parentId_tuple)
if parentId_tuple in self.works_cache:
self.check_cache(tm, album, track, parentId_tuple, not_in_cache)
else:
not_in_cache.append(parentId_tuple)
return not_in_cache
def work_not_in_cache(self, release_id, album, track, workId_tuple):
"""
Determine actions if work not in cache (is it the top or do we need to look up?)
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:param track:
:param workId_tuple:
:return:
"""
write_log(
release_id,
'debug',
'Processing work_not_in_cache for workId %s',
workId_tuple)
## NB the first condition below is to prevent the side effect of assigning a dictionary entry in self.parts for workId with no details
if workId_tuple in self.parts and 'no_parent' in self.parts[workId_tuple] and (
self.USE_CACHE or self.options[track]["cwp_use_sk"]) and self.parts[workId_tuple]['no_parent']:
write_log(release_id, 'info', '%s is top work', workId_tuple)
self.top_works[(track, album)]['workId'] = workId_tuple
if album in self.top:
if workId_tuple not in self.top[album]:
self.top[album].append(workId_tuple)
else:
self.top[album] = [workId_tuple]
else:
write_log(
release_id,
'info',
'Calling work_add_track to look up parents for %s',
workId_tuple)
for workId in workId_tuple:
self.work_add_track(album, track, workId, 0)
write_log(
release_id,
'debug',
'End of work_not_in_cache for workId %s',
workId_tuple)
def work_add_track(self, album, track, workId, tries, user_data=True):
"""
Add the work to the lookup queue
:param user_data:
:param album:
:param track:
:param workId:
:param tries: number of lookup attempts
:return:
"""
release_id = track.metadata['musicbrainz_albumid']
write_log(
release_id,
'debug',
"ADDING WORK TO LOOKUP QUEUE for work %s",
workId)
self.album_add_request(release_id, album)
# to change the _requests variable to indicate that there are pending
# requests for this item and delay Picard from finalizing the album
write_log(
release_id,
'debug',
"Added lookup request for id %s. Requests = %s",
workId,
album._requests)
if self.works_queue.append(
workId,
(track,
album)): # All work combos are queued, but only new workIds are passed to XML lookup
host = config.setting["server_host"]
port = config.setting["server_port"]
path = "/ws/2/%s/%s" % ('work', workId)
if config.setting['cwp_aliases'] and config.setting['cwp_aliases_tag_text']:
if config.setting['cwp_aliases_tags_user'] and user_data:
login = True
tag_type = '+tags +user-tags'
else:
login = False
tag_type = '+tags'
else:
login = False
tag_type = ''
queryargs = {
"inc": "work-rels+artist-rels+label-rels+place-rels+aliases" +
tag_type}
write_log(
release_id,
'debug',
"Initiating XML lookup for %s......",
workId)
if release_id in release_status and 'lookups' in release_status[release_id]:
release_status[release_id]['lookups'] += 1
return album.tagger.webservice.get(
host,
port,
path,
partial(
self.work_process,
workId,
tries),
# parse_response_type="xml",
priority=True,
important=False,
mblogin=login,
queryargs=queryargs)
else:
write_log(
release_id,
'debug',
"Work is already in queue: %s",
workId)
##########################################################################
# SECTION 2 - Works processing #
# NB These functions may operate asynchronously over multiple albums (as well as multiple tracks) #
##########################################################################
def work_process(self, workId, tries, response, reply, error):
"""
Top routine to process the XML/JSON node response from the lookup
NB This function may operate over multiple albums (as well as multiple tracks)
:param workId:
:param tries:
:param response:
:param reply:
:param error:
:return:
"""
if error:
tuples = self.works_queue.remove(workId)
for track, album in tuples:
release_id = track.metadata['musicbrainz_albumid']
write_log(
release_id,
'warning',
"%r: Network error retrieving work record. Error code %r",
workId,
error)
write_log(
release_id,
'debug',
"Removed request after network error. Requests = %s",
album._requests)
if tries < self.MAX_RETRIES:
user_data = True
write_log(release_id, 'debug', "REQUEUEING...")
if str(error) == '204': # Authentication error
write_log(
release_id, 'debug', "... without user authentication")
user_data = False
self.append_tag(
release_id,
track.metadata,
'~cwp_error',
'3. Authentication failure - data retrieval omits user-specific requests')
self.work_add_track(
album, track, workId, tries + 1, user_data)
else:
write_log(
release_id,
'error',
"EXHAUSTED MAX RE-TRIES for XML lookup for track %s",
track)
if self.ERROR:
self.append_tag(
release_id,
track.metadata,
'~cwp_error',
"4. ERROR: MISSING METADATA due to network errors. Re-try or fix manually.")
self.album_remove_request(release_id, album)
return
tuples = self.works_queue.remove(workId)
if tuples:
new_queue = []
prev_album = None
album = tuples[0][1] # just added to prevent technical "reference before assignment" error
release_id = 'No_release_id'
for (track, album) in tuples:
release_id = track.metadata['musicbrainz_albumid']
# Note that this need to be set here as the work may cover
# multiple albums
if album != prev_album:
write_log(release_id, 'debug',
"Work_process. FOUND WORK: %s for album %s",
workId, album)
write_log(
release_id,
'debug',
"Requests for album %s = %s",
album,
album._requests)
prev_album = album
write_log(release_id, 'info', "RESPONSE = %s", response)
# find the id_tuple(s) key with workId in it
wid_list = []
for w in self.work_listing[album]:
if workId in w and w not in wid_list:
wid_list.append(w)
write_log(
release_id,
'info',
'wid_list for %s is %s',
workId,
wid_list)
for wid in wid_list: # wid is a tuple
write_log(
release_id,
'info',
'processing workId tuple: %r',
wid)
metaList = self.work_process_metadata(
release_id, workId, wid, track, response)
parentList = metaList[0]
# returns [[parent id], [parent name], attribute_list] or None if no parent
# found
arrangers = metaList[1]
# not just arrangers - also composers, lyricists etc.
if wid in self.parts:
if arrangers:
if 'arrangers' in self.parts[wid]:
self.parts[wid]['arrangers'] += arrangers
else:
self.parts[wid]['arrangers'] = arrangers
if parentList:
# first fix the sort order of multi-works at the prev level
# so that recordings of multiple movements of the same parent work will have the
# movements listed in the correct order (i.e.
# ordering-key, if available)
if len(wid) > 1:
for idx in wid:
if idx == workId:
match_tree = [
'relations',
'target-type:work',
'direction:backward',
'ordering-key']
parse_result = parse_data(
release_id, response, [], *match_tree)
write_log(
release_id,
'info',
'multi-works - ordering key for id %s is %s',
idx,
parse_result)
if parse_result:
if isinstance(
parse_result[0], str) and parse_result[0].isdigit():
key = int(parse_result[0])
elif isinstance(parse_result[0], int):
key = parse_result[0]
else:
key = 9999
self.parts[wid]['order'][idx] = key
parentIds = parentList[0]
parents = parentList[1]
parent_attributes = parentList[2]
write_log(
release_id,
'info',
'Parents - ids: %s, names: %s',
parentIds,
parents)
# remove any parents that are descendants of wid as
# they will result in circular references
del_list = []
for i, parentId in enumerate(parentIds):
for work_item in wid:
if work_item in self.child_listing and parentId in self.child_listing[
work_item]:
del_list.append(i)
for i in list(set(del_list)):
removed_id = parentIds.pop(i)
removed_name = parents.pop(i)
write_log(
release_id, 'error', "Found parent which is descendant of child - "
"not using, to prevent circular references. id = %s,"
" name = %s", removed_id, removed_name)
tm = track.metadata
self.append_tag(
release_id,
tm,
'~cwp_error',
'5. Found parent which which is descendant of child - not using '
'to prevent circular references. id = ' +
removed_id +
', name = ' +
removed_name)
is_collection = False
for attribute in parent_attributes:
if attribute['collection']:
is_collection = True
break
# de-dup parent ids before we start
parentIds = list(
collections.OrderedDict.fromkeys(parentIds))
# add descendants to checklist to prevent recursion
for p in parentIds:
for w in wid:
self.child_listing[p].append(w)
if w in self.child_listing:
self.child_listing[p] += self.child_listing[w]
if parentIds:
if wid in self.works_cache:
# Make sure we haven't done this
# relationship before, perhaps for another
# album
if not (set(
self.works_cache[wid]) >= set(parentIds)):
prev_ids = tuple(self.works_cache[wid])
prev_name = self.parts[prev_ids]['name']
self.works_cache[wid] = add_list_uniquely(
self.works_cache[wid], parentIds)
self.parts[wid]['parent'] = add_list_uniquely(
self.parts[wid]['parent'], parentIds)
index = self.work_listing[album].index(
prev_ids)
new_id_list = add_list_uniquely(
list(prev_ids), parentIds)
new_ids = tuple(new_id_list)
self.work_listing[album][index] = new_ids
self.parts[new_ids] = self.parts[prev_ids]
#del self.parts[prev_ids] # Removed from here to deal with multi-parent parts. De-dup now takes place in process_albums.
self.parts[new_ids]['name'] = add_list_uniquely(
prev_name, parents)
parentIds = new_id_list
write_log(
release_id,
'debug',
"In work_process. Changed wid in self.part: prev_ids = %s, new_ids = %s, prev_name = %s, new name = %s",
prev_ids,
new_ids,
prev_name,
self.parts[new_ids]['name'])
else:
self.works_cache[wid] = parentIds
self.parts[wid]['parent'] = parentIds
self.parts[tuple(parentIds)
]['name'] = parents
self.work_listing[album].append(
tuple(parentIds))
# de-duplicate the parent names
# self.parts[tuple(parentIds)]['name'] = list(
# collections.OrderedDict.fromkeys(self.parts[tuple(parentIds)]['name']))
# list(set()) won't work as need to retain order
self.parts[tuple(parentIds)]['is_collection'] = is_collection
write_log(
release_id,
'debug',
"In work_process. self.parts[%s]['is_collection']: %s",
tuple(parentIds),
self.parts[tuple(parentIds)]['is_collection'])
# de-duplicate the parent ids also, otherwise they will be treated as a separate parent
# in the trackback structure
self.parts[wid]['parent'] = list(
collections.OrderedDict.fromkeys(
self.parts[wid]['parent']))
self.works_cache[wid] = list(
collections.OrderedDict.fromkeys(
self.works_cache[wid]))
write_log(
release_id,
'info',
'Added parent ids to work_listing: %s, [Requests = %s]',
parentIds,
album._requests)
write_log(
release_id,
'info',
'work_listing after adding parents: %s',
self.work_listing[album])
# the higher-level work might already be in
# cache from another album
if tuple(
parentIds) in self.works_cache and self.USE_CACHE:
not_in_cache = self.check_cache(
track.metadata, album, track, tuple(parentIds), [])
for workId_tuple in not_in_cache:
new_queue.append(
(release_id, album, track, workId_tuple))
else:
if not self.USE_CACHE:
if tuple(
parentIds) in self.works_cache:
del self.works_cache[tuple(
parentIds)]
for parentId in parentIds:
new_queue.append(
(release_id, album, track, (parentId,)))
else:
# so we remember we looked it up and found none
self.parts[wid]['no_parent'] = True
self.top_works[(track, album)]['workId'] = wid
if wid not in self.top[album]:
self.top[album].append(wid)
write_log(
release_id, 'info', "TOP[album]: %s", self.top[album])
else:
# so we remember we looked it up and found none
self.parts[wid]['no_parent'] = True
self.top_works[(track, album)]['workId'] = wid
self.top[album].append(wid)
write_log(
release_id,
'debug',
"End of tuple processing for workid %s in album %s, track %s,"
" requests remaining = %s, new queue is %r",
workId,
album,
track,
album._requests,
new_queue)
self.album_remove_request(release_id, album)
for queued_item in new_queue:
write_log(
release_id,
'info',
'Have a new queue: queued_item = %r',
queued_item)
write_log(
release_id,
'debug',
'Penultimate end of work_process for %s (subject to parent lookups in "new_queue")',
workId)
for queued_item in new_queue:
self.work_not_in_cache(
queued_item[0],
queued_item[1],
queued_item[2],
queued_item[3])
write_log(release_id, 'debug',
'Ultimate end of work_process for %s', workId)
if album._requests == 0:
self.process_album(release_id, album)
album._finalize_loading(None)
release_status[release_id]['works-done'] = datetime.now()
close_log(release_id, 'works')
def work_process_metadata(self, release_id, workId, wid, track, response):
"""
Process XML node
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
NB release_id may be from a different album than the original, if works lookups are identical
:param workId:
:param wid: The work id tuple of which workId is a member
:param track:
:param response:
:return:
"""
write_log(release_id, 'debug', "In work_process_metadata")
all_tags = parse_data(release_id, response, [], 'tags', 'name')
self.parts[wid]['folks_genres'] = all_tags
self.parts[wid]['worktype_genres'] = parse_data(
release_id, response, [], 'type')
key = parse_data(
release_id,
response,
[],
'attributes',
'type:Key',
'value')
self.parts[wid]['key'] = key
composed_begin_dates = year(
parse_data(
release_id,
response,
[],
'relations',
'target-type:artist',
'type:composer',
'begin'))
composed_end_dates = year(
parse_data(
release_id,
response,
[],
'relations',
'target-type:artist',
'type:composer',
'end'))
if composed_begin_dates == composed_end_dates:
composed_dates = composed_begin_dates
else:
composed_dates = list(
zip(composed_begin_dates, composed_end_dates))
composed_dates = [y + DATE_SEP + z if y != z else y for y, z in composed_dates]
self.parts[wid]['composed_dates'] = composed_dates
published_begin_dates = year(
parse_data(
release_id,
response,
[],
'relations',
'target-type:label',
'type:publishing',
'begin'))
published_end_dates = year(
parse_data(
release_id,
response,
[],
'relations',
'target-type:label',
'type:publishing',
'end'))
if published_begin_dates == published_end_dates:
published_dates = published_begin_dates
else:
published_dates = list(
zip(published_begin_dates, published_end_dates))
published_dates = [x + DATE_SEP + y for x, y in published_dates]
self.parts[wid]['published_dates'] = published_dates
premiered_begin_dates = year(
parse_data(
release_id,
response,
[],
'relations',
'target-type:place',
'type:premiere',
'begin'))
premiered_end_dates = year(
parse_data(
release_id,
response,
[],
'relations',
'target-type:place',
'type:premiere',
'end'))
if premiered_begin_dates == premiered_end_dates:
premiered_dates = premiered_begin_dates
else:
premiered_dates = list(
zip(premiered_begin_dates, premiered_end_dates))
premiered_dates = [x + DATE_SEP + y for x, y in premiered_dates]
self.parts[wid]['premiered_dates'] = premiered_dates
lang = get_preferred_artist_language(config)
if lang:
alias = parse_data(release_id, response, [], 'aliases',
'locale:' + lang, 'primary:True', 'name')
user_tags = parse_data(
release_id, response, [], 'user-tags', 'name')
if config.setting['cwp_aliases_tags_user']:
tags = user_tags
else:
tags = all_tags
if alias:
self.parts[wid]['alias'] = self.parts[wid]['name'][:]
self.parts[wid]['tags'] = tags
for ind, w in enumerate(wid):
if w == workId:
# alias should be a one item list but just in case it isn't...
if len(self.parts[wid]['alias']) > ind:
# The condition here is just to trap errors caused by database inconsistencies
# (e.g. a part is shown as a recording of two works, one of which is an arrangement
# of the other - this can create a two-item wid with a one-item self.parts[wid]['name']
self.parts[wid]['alias'][ind] = '; '.join(
alias)
relation_list = parse_data(release_id, response, [], 'relations')
return self.work_process_relations(
release_id, track, workId, wid, relation_list)
def work_process_relations(
self,
release_id,
track,
workId,
wid,
relations):
"""
Find the parents etc.
NB track is just the last album/track for this work - used as being
representative for options identification. If this is inconsistent (e.g. different collections
option for albums with the same works) then the latest added track will over-ride others' settings).
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param track:
:param workId:
:param wid:
:param relations:
:return:
"""
write_log(
release_id,
'debug',
"In work_process_relations. Relations--> %s",
relations)
if track:
options = self.options[track]
else:
options = config.setting
new_workIds = []
new_works = []
attributes_list = []
relation_attributes = parse_data(
release_id,
relations,
[],
'target-type:work',
'type:parts',
'direction:backward',
'attributes')
new_work_list = []
write_log(
release_id,
'debug',
"relation_attributes--> %s",
relation_attributes)
for relation_attribute in relation_attributes:
if (
'part of collection' not in relation_attribute) or options['cwp_collections']:
new_work_list += parse_data(release_id,
relations,
[],
'target-type:work',
'type:parts',
'direction:backward',
'work')
attributes_dict = {'collection' : ('part of collection' in relation_attribute),
'movements' : ('movement' in relation_attribute),
'acts' : ('act' in relation_attribute),
'numbers' : ('number' in relation_attribute)}
attributes_list += [attributes_dict]
if (
'part of collection' in relation_attribute) and not options['cwp_collections']:
write_log(
release_id,
'info',
'Not getting parent work because relationship is "part of collection" and option not selected')
if new_work_list:
write_log(
release_id,
'info',
'new_work_list: %s',
new_work_list)
new_workIds = parse_data(release_id, new_work_list, [], 'id')
new_works = parse_data(release_id, new_work_list, [], 'title')
else:
arrangement_of = parse_data(
release_id,
relations,
[],
'target-type:work',
'type:arrangement',
'direction:backward',
'work')
if arrangement_of and options['cwp_arrangements']:
new_workIds = parse_data(release_id, arrangement_of, [], 'id')
new_works = parse_data(release_id, arrangement_of, [], 'title')
self.parts[wid]['arrangement'] = True
else:
medley_of = parse_data(
release_id,
relations,
[],
'target-type:work',
'type:medley',
'work')
direction = parse_data(
release_id,
relations,
[],
'target-type:work',
'type:medley',
'direction')
if 'backward' not in direction:
write_log(
release_id, 'info', 'Medley_of: %s', medley_of)
if medley_of and options['cwp_medley']:
medley_list = []
medley_id_list = []
for medley_item in medley_of:
medley_list = medley_list + \
parse_data(release_id, medley_item, [], 'title')
medley_id_list = medley_id_list + \
parse_data(release_id, medley_item, [], 'id')
# (parse_data is a list...)
new_workIds = medley_id_list
new_works = medley_list
write_log(
release_id, 'info', 'Medley_list: %s', medley_list)
self.parts[wid]['medley_list'] = medley_list
write_log(
release_id,
'info',
'New works: ids: %s, names: %s, attributes: %s',
new_workIds,
new_works,
attributes_list)
artists = get_artists(
options,
release_id,
{},
relations,
'work')['artists']
# artist_types = ['arranger', 'instrument arranger', 'orchestrator', 'composer', 'writer', 'lyricist',
# 'librettist', 'revised by', 'translator', 'reconstructed by', 'vocal arranger']
write_log(release_id, 'info', "ARTISTS %s", artists)
workItems = (new_workIds, new_works, attributes_list)
itemsFound = [workItems, artists]
return itemsFound
@staticmethod
def album_add_request(release_id, album):
"""
To keep track as to whether all lookups have been processed
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:return:
"""
album._requests += 1
write_log(
release_id,
'debug',
"Added album request - requests: %s",
album._requests)
@staticmethod
def album_remove_request(release_id, album):
"""
To keep track as to whether all lookups have been processed
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:return:
"""
album._requests -= 1
write_log(
release_id,
'debug',
"Removed album request - requests: %s",
album._requests)
##################################################
# SECTION 3 - Organise tracks and works in album #
##################################################
def process_album(self, release_id, album):
"""
Top routine to run end-of-album processes
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:return:
"""
write_log(release_id, 'debug', "PROCESS ALBUM %s", album)
release_status[release_id]['done-lookups'] = datetime.now()
# De-duplicate names in self.parts, maintaining order (in case part names have been arrived at via multiple paths)
for part_item in self.parts:
if 'name' in self.parts[part_item]:
self.parts[part_item]['name'] = list(collections.OrderedDict.fromkeys(str_to_list(self.parts[part_item]['name'])))
# populate the inverse hierarchy
write_log(release_id, 'info', "Cache: %s", self.works_cache)
write_log(release_id, 'info', "Work listing %s", self.work_listing)
alias_tag_list = config.setting['cwp_aliases_tag_text'].split(',')
for i, tag_item in enumerate(alias_tag_list):
alias_tag_list[i] = tag_item.strip()
for workId in self.work_listing[album]:
if workId in self.parts:
write_log(
release_id,
'info',
'Processing workid: %s',
workId)
write_log(
release_id,
'info',
'self.work_listing[album]: %s',
self.work_listing[album])
if len(workId) > 1:
# fix the order of names using ordering keys gathered in
# work_process
if 'order' in self.parts[workId]:
seq = []
for idx in workId:
if idx in self.parts[workId]['order']:
seq.append(self.parts[workId]['order'][idx])
else:
# for the possibility of workids not part of
# the same parent and not all ordered
seq.append(999)
zipped_names = zip(self.parts[workId]['name'], seq)
sorted_tups = sorted(zipped_names, key=lambda x: x[1])
self.parts[workId]['name'] = [x[0]
for x in sorted_tups]
# use aliases where appropriate
# name is a list - need a string to test for Latin chars
name_string = '; '.join(self.parts[workId]['name'])
if config.setting['cwp_aliases']:
if config.setting['cwp_aliases_all'] or (
config.setting['cwp_aliases_greek'] and not only_roman_chars(name_string)) or (
'tags' in self.parts[workId] and any(
x in self.parts[workId]['tags'] for x in alias_tag_list)):
if 'alias' in self.parts[workId] and self.parts[workId]['alias']:
self.parts[workId]['name'] = self.parts[workId]['alias'][:]
topId = None
write_log(
release_id,
'info',
'Works_cache: %s',
self.works_cache)
if workId in self.works_cache:
parentIds = tuple(self.works_cache[workId])
# for parentId in parentIds:
write_log(
release_id,
'debug',
"Create inverses: %s, %s",
workId,
parentIds)
if parentIds in self.partof[album]:
if workId not in self.partof[album][parentIds]:
self.partof[album][parentIds].append(workId)
else:
self.partof[album][parentIds] = [workId]
write_log(release_id, 'info', "Partof: %s",
self.partof[album][parentIds])
if 'no_parent' in self.parts[parentIds]:
# to handle case if album includes works already in
# cache from a different album
if self.parts[parentIds]['no_parent']:
topId = parentIds
else:
topId = workId
if topId:
if album in self.top:
if topId not in self.top[album]:
self.top[album].append(topId)
else:
self.top[album] = [topId]
# work out the full hierarchy and part levels
height = 0
write_log(
release_id,
'info',
"TOP: %s, \nALBUM: %s, \nTOP[ALBUM]: %s",
self.top,
album,
self.top[album])
if len(self.top[album]) > 1:
single_work_album = 0
else:
single_work_album = 1
for topId in self.top[album]:
self.create_trackback(release_id, album, topId)
write_log(
release_id,
'info',
"Top id = %s, Name = %s",
topId,
self.parts[topId]['name'])
write_log(
release_id,
'info',
"Trackback before levels: %s",
self.trackback[album][topId])
work_part_levels = self.level_calc(
release_id, self.trackback[album][topId], height)
write_log(
release_id,
'info',
"Trackback after levels: %s",
self.trackback[album][topId])
# determine the level which will be the principal 'work' level
if work_part_levels >= 3:
ref_level = work_part_levels - single_work_album
else:
ref_level = work_part_levels
# extended metadata scheme won't display more than 3 work levels
# ref_level = min(3, ref_level)
ref_height = work_part_levels - ref_level
top_info = {
'levels': work_part_levels,
'id': topId,
'name': self.parts[topId]['name'],
'single': single_work_album}
# set the metadata in sequence defined by the work structure
answer = self.process_trackback(
release_id,
album,
self.trackback[album][topId],
ref_height,
top_info)
##
# trackback is a tree in the form {album: {id: , children:{id: , children{},
# id: etc},
# id: etc} }
# process_trackback uses the trackback tree to derive title and level_0 based hierarchies
# from the structure. It also returns a tuple (id, tracks), where tracks has the structure
# {'track': [(track, height), (track, height), ...tuples...]
# 'work': [[worknames], [worknames], ...lists...]
# 'tracknumber': [num, num, ...floats of form n.nnn = disc.track...]
# 'title': [title, title, ...strings...]}
# each list is the same length - i.e. the number of tracks for the top work
# there can be more than one workname for a track
# height is the number of part levels for the related track
##
if answer:
tracks = sorted(zip(answer[1]['track'], answer[1]['tracknumber']), key=lambda x: x[1])
# need them in tracknumber sequence for the movement numbers to be correct
write_log(release_id, 'info', "TRACKS: %s", tracks)
# work_part_levels = self.trackback[album][topId]['depth']
movement_count = 0
prev_movementgroup = None
for track, _ in tracks:
movement_count += 1
track_meta = track[0]
tm = track_meta.metadata
if '~cwp_workid_0' in tm:
workIds = tuple(str_to_list(tm['~cwp_workid_0']))
if workIds:
count = 0
self.process_work_artists(
release_id, album, track_meta, workIds, tm, count)
title_work_levels = 0
if '~cwp_title_work_levels' in tm:
title_work_levels = int(tm['~cwp_title_work_levels'])
movementgroup = self.extend_metadata(
release_id,
top_info,
track_meta,
ref_height,
title_work_levels) # revise for new data
if track_meta not in self.tracks[album]:
self.tracks[album][track_meta] = {}
if movementgroup:
if movementgroup != prev_movementgroup:
movement_count = 1
write_log(
release_id,
'debug',
"processing movements for track: %s - movement-group is %s",
track, movementgroup)
self.tracks[album][track_meta]['movement-group'] = movementgroup
self.tracks[album][track_meta]['movement-number'] = movement_count
self.parts[tuple(movementgroup)]['movement-total'] = movement_count
prev_movementgroup = movementgroup
write_log(
release_id,
'debug',
"FINISHED TRACK PROCESSING FOR Top work id: %s",
topId)
# Need to redo the loop so that all album-wide tm is updated before
# publishing
for track, movement_info in self.tracks[album].items():
self.publish_metadata(release_id, album, track, movement_info)
# #
# The messages below are normally commented out as they get VERY long if there are a lot of albums loaded
# For extreme debugging, remove the comments and just run one or a few albums
# Do not forget to comment out again.
# #
# write_log(release_id, 'info', 'Self.parts: %s', self.parts)
# write_log(release_id, 'info', 'Self.trackback: %s', self.trackback)
# tidy up
self.trackback[album].clear()
# Finally process the orphan tracks
if album in self.orphan_tracks:
for track in self.orphan_tracks[album]:
tm = track.metadata
options = self.options[track]
if options['cwp_derive_works_from_title']:
work, movt, inter_work = self.derive_from_title(release_id, track, tm['title'])
tm['~cwp_extended_work'] = tm['~cwp_extended_groupheading'] = tm['~cwp_title_work'] = \
tm['~cwp_title_groupheading'] = tm['~cwp_work'] = tm['~cwp_groupheading']= work
tm['~cwp_part'] = tm['~cwp_extended_part'] = tm['~cwp_title_part_0'] = movt
tm['~cwp_inter_work'] = tm['~cwp_extended_inter_work'] = tm['~cwp_inter_title_work'] = inter_work
self.publish_metadata(release_id, album, track)
write_log(release_id, 'debug', "PROCESS ALBUM function complete")
def create_trackback(self, release_id, album, parentId):
"""
Create an inverse listing of the work-parent relationships
:param release_id:
:param album:
:param parentId:
:return: trackback for a given parentId
"""
write_log(release_id, 'debug', "Create trackback for %s", parentId)
if parentId in self.partof[album]: # NB parentId is a tuple
for child in self.partof[album][parentId]: # NB child is a tuple
if child in self.partof[album]:
child_trackback = self.create_trackback(
release_id, album, child)
self.append_trackback(
release_id, album, parentId, child_trackback)
else:
self.append_trackback(
release_id, album, parentId, self.trackback[album][child])
return self.trackback[album][parentId]
else:
return self.trackback[album][parentId]
def append_trackback(self, release_id, album, parentId, child):
"""
Recursive process to populate trackback
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:param parentId:
:param child:
:return:
"""
write_log(release_id, 'debug', "In append_trackback...")
if parentId in self.trackback[album]: # NB parentId is a tuple
if 'children' in self.trackback[album][parentId]:
if child not in self.trackback[album][parentId]['children']:
write_log(release_id, 'info', "TRYING TO APPEND...")
self.trackback[album][parentId]['children'].append(child)
write_log(
release_id,
'info',
"...PARENT %s - ADDED %s as child",
self.parts[parentId]['name'],
child)
else:
write_log(
release_id,
'info',
"Parent %s already has %s as child",
parentId,
child)
else:
self.trackback[album][parentId]['children'] = [child]
write_log(
release_id,
'info',
"Existing PARENT %s - ADDED %s as child",
self.parts[parentId]['name'],
child)
else:
self.trackback[album][parentId]['id'] = parentId
self.trackback[album][parentId]['children'] = [child]
write_log(
release_id,
'info',
"New PARENT %s - ADDED %s as child",
self.parts[parentId]['name'],
child)
write_log(
release_id,
'info',
"APPENDED TRACKBACK: %s",
self.trackback[album][parentId])
return self.trackback[album][parentId]
def level_calc(self, release_id, trackback, height):
"""
Recursive process to determine the max level for a work
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param trackback:
:param height: number of levels above this one
:return:
"""
write_log(release_id, 'debug', 'In level_calc process')
if 'children' not in trackback:
write_log(release_id, 'info', "Got to bottom")
trackback['height'] = height
trackback['depth'] = 0
return 0
else:
trackback['height'] = height
height += 1
max_depth = 0
for child in trackback['children']:
write_log(release_id, 'info', "CHILD: %s", child)
depth = self.level_calc(release_id, child, height) + 1
write_log(release_id, 'info', "DEPTH: %s", depth)
max_depth = max(depth, max_depth)
trackback['depth'] = max_depth
return max_depth
###########################################
# SECTION 4 - Process tracks within album #
###########################################
def process_trackback(
self,
release_id,
album_req,
trackback,
ref_height,
top_info):
"""
Set work structure metadata & govern other metadata-setting processes
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album_req:
:param trackback:
:param ref_height:
:param top_info:
:return:
"""
write_log(
release_id,
'debug',
"IN PROCESS_TRACKBACK. Trackback = %s",
trackback)
tracks = collections.defaultdict(dict)
process_now = False
if 'meta' in trackback:
for track, album in trackback['meta']:
if album_req == album:
process_now = True
if process_now or 'children' not in trackback:
if 'meta' in trackback and 'id' in trackback and 'depth' in trackback and 'height' in trackback:
write_log(release_id, 'info', "Processing level 0")
depth = trackback['depth']
height = trackback['height']
workId = tuple(trackback['id'])
if depth != 0:
if 'children' in trackback:
child_response = self.process_trackback_children(
release_id, album_req, trackback, ref_height, top_info, tracks)
tracks = child_response[1]
write_log(
release_id,
'info',
'Bottom level for this trackback is higher level elsewhere - adjusting levels')
depth = 0
write_log(release_id, 'info', "WorkId: %s, Work name: %s", workId, self.parts[workId]['name'])
for track, album in trackback['meta']:
if album == album_req:
write_log(release_id, 'info', "Track: %s", track)
tm = track.metadata
write_log(
release_id, 'info', "Track metadata = %s", tm)
tm['~cwp_workid_' + str(depth)] = workId
self.write_tags(release_id, track, tm, workId)
self.make_annotations(release_id, track, workId)
# strip leading and trailing spaces from work names
if isinstance(self.parts[workId]['name'], str):
worktemp = self.parts[workId]['name'].strip()
else:
for index, it in enumerate(
self.parts[workId]['name']):
self.parts[workId]['name'][index] = it.strip()
worktemp = self.parts[workId]['name']
if isinstance(top_info['name'], str):
toptemp = top_info['name'].strip()
else:
for index, it in enumerate(top_info['name']):
top_info['name'][index] = it.strip()
toptemp = top_info['name']
tm['~cwp_work_' + str(depth)] = worktemp
tm['~cwp_part_levels'] = str(height)
tm['~cwp_work_part_levels'] = str(top_info['levels'])
tm['~cwp_workid_top'] = top_info['id']
tm['~cwp_work_top'] = toptemp
tm['~cwp_single_work_album'] = top_info['single']
write_log(
release_id, 'info', "Track metadata = %s", tm)
if 'track' in tracks:
tracks['track'].append((track, height))
else:
tracks['track'] = [(track, height)]
tracks['tracknumber'] = [int(tm['discnumber']) + (int(tm['tracknumber']) / 1000)]
# Hopefully no more than 999 tracks per disc!
write_log(release_id, 'info', "Tracks: %s", tracks)
response = (workId, tracks)
write_log(release_id, 'debug', "LEAVING PROCESS_TRACKBACK")
write_log(
release_id,
'info',
"depth %s Response = %s",
depth,
response)
return response
else:
return None
else:
response = self.process_trackback_children(
release_id, album_req, trackback, ref_height, top_info, tracks)
return response
def process_trackback_children(
self,
release_id,
album_req,
trackback,
ref_height,
top_info,
tracks):
"""
TODO add some better documentation!
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album_req:
:param trackback:
:param ref_height:
:param top_info:
:param tracks:
:return:
"""
if 'id' in trackback and 'depth' in trackback and 'height' in trackback:
write_log(
release_id,
'debug',
'In process_children_trackback for trackback %s',
trackback)
depth = trackback['depth']
height = trackback['height']
parentId = tuple(trackback['id'])
parent = self.parts[parentId]['name']
width = 0
for child in trackback['children']:
width += 1
write_log(
release_id,
'info',
"child trackback = %s",
child)
answer = self.process_trackback(
release_id, album_req, child, ref_height, top_info)
if answer:
workId = answer[0]
child_tracks = answer[1]['track']
for track in child_tracks:
track_meta = track[0]
track_height = track[1]
part_level = track_height - height
write_log(
release_id,
'debug',
"Calling set metadata %s",
(part_level,
workId,
parentId,
parent,
track_meta))
self.set_metadata(
release_id, part_level, workId, parentId, parent, track_meta)
if 'track' in tracks:
tracks['track'].append(
(track_meta, track_height))
else:
tracks['track'] = [(track_meta, track_height)]
tm = track_meta.metadata
# ~cwp_title if composer had to be removed
title = tm['~cwp_title'] or tm['title']
if 'title' in tracks:
tracks['title'].append(title)
else:
tracks['title'] = [title]
# to make sure we get it as a list
work = tm.getall('~cwp_work_0')
if 'work' in tracks:
tracks['work'].append(work)
else:
tracks['work'] = [work]
if 'tracknumber' not in tm:
tm['tracknumber'] = 0
if 'discnumber' not in tm:
tm['discnumber'] = 0
if 'tracknumber' in tracks:
tracks['tracknumber'].append(
int(tm['discnumber']) + (int(tm['tracknumber']) / 1000))
else:
tracks['tracknumber'] = [
int(tm['discnumber']) + (int(tm['tracknumber']) / 1000)]
if tracks and 'track' in tracks:
track = tracks['track'][0][0]
# NB this will only be the first track of tracks, but its
# options will be used for the structure
self.derive_from_structure(
release_id, top_info, tracks, height, depth, width, 'title')
if self.options[track]["cwp_level0_works"]:
# replace hierarchical works with those from work_0 (for
# consistency)
self.derive_from_structure(
release_id, top_info, tracks, height, depth, width, 'work')
write_log(
release_id,
'info',
"Trackback result for %s = %s",
parentId,
tracks)
response = parentId, tracks
write_log(
release_id,
'debug',
"LEAVING PROCESS_CHILD_TRACKBACK depth %s Response = %s",
depth,
response)
return response
else:
return None
else:
return None
def derive_from_structure(
self,
release_id,
top_info,
tracks,
height,
depth,
width,
name_type):
"""
Derive title (or work level-0) components from MB hierarchical work structure
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param top_info:
{'levels': work_part_levels,'id': topId,'name': self.parts[topId]['name'],'single': single_work_album}
:param tracks:
{'track':[(track1, height1), (track2, height2), ...], 'work': [work1, work2,...],
'title': [title1, title2, ...], 'tracknumber': [tracknumber1, tracknumber2, ...]}
where height is the number of levels in total in the branch for that track (i.e. height 1 => work_0 & work_1)
:param height: number of levels above the current one
:param depth: maximum number of levels
:param width: number of siblings
:param name_type: work or title
:return:
"""
if 'track' in tracks:
track = tracks['track'][0][0]
# NB this will only be the first track of tracks, but its
# options will be used for the structure
single_work_track = False # default
write_log(
release_id,
'debug',
"Deriving info for %s from structure for tracks %s",
name_type,
tracks['track'])
write_log(
release_id,
'info',
'%ss are %r',
name_type,
tracks[name_type])
if 'tracknumber' in tracks:
sorted_tracknumbers = sorted(tracks['tracknumber'])
else:
sorted_tracknumbers = None
write_log(
release_id,
'info',
"SORTED TRACKNUMBERS: %s",
sorted_tracknumbers)
common_len = 0
if name_type in tracks:
meta_str = "_title" if name_type == 'title' else "_X0"
# in case of works, could be a list of lists
name_list = tracks[name_type]
write_log(
release_id,
'info',
"%s list %s",
name_type,
name_list)
if len(name_list) == 1: # only one track in this work so try and extract using colons
single_work_track = True
track_height = tracks['track'][0][1]
if track_height - height > 0: # track_height - height == part_level
if name_type == 'title':
write_log(
release_id,
'debug',
"Single track work. Deriving directly from title text: %s",
track)
ti = name_list[0]
common_subset = self.derive_from_title(
release_id, track, ti)[0]
else:
common_subset = ""
else:
common_subset = name_list[0]
write_log(
release_id,
'info',
"%s is single-track work. common_subset is set to %s",
tracks['track'][0][0],
common_subset)
if common_subset:
common_len = len(common_subset)
else:
common_len = 0
else: # NB if names are lists of lists, we'll assume they all start the same way
if isinstance(name_list[0], list):
compare = name_list[0][0].split()
else:
# a list of the words in the first name
compare = name_list[0].split()
for name_item in name_list:
if isinstance(name_item, list):
name = name_item[0]
else:
name = name_item
lcs = longest_common_sequence(compare, name.split())
compare = lcs['sequence']
if not compare:
common_len = 0
break
if lcs['length'] > 0:
common_subset = " ".join(compare)
write_log(
release_id,
'info',
"Common subset from %ss at level %s, item name %s ..........",
name_type,
tracks['track'][0][1] -
height,
name)
write_log(
release_id, 'info', "..........is %s", common_subset)
common_len = len(common_subset)
write_log(
release_id,
'info',
"checked for common sequence - length is %s",
common_len)
for track_index, track_item in enumerate(tracks['track']):
track_meta = track_item[0]
tm = track_meta.metadata
top_level = int(tm['~cwp_part_levels'])
part_level = track_item[1] - height
if common_len > 0:
self.create_work_levels(release_id, name_type, tracks, track, track_index,
track_meta, tm, meta_str, part_level, depth, width, common_len)
else: # (no common substring at this level)
if name_type == 'work':
write_log(release_id, 'info',
'single track work - indicator = %s. track = %s, part_level = %s, top_level = %s',
single_work_track, track_item, part_level, top_level)
if part_level >= top_level: # so it won't be covered by top-down action
for level in range(
0, part_level + 1): # fill in the missing work names from the canonical list
if '~cwp' + meta_str + '_work_' + \
str(level) not in tm:
tm['~cwp' +
meta_str +
'_work_' +
str(level)] = tm['~cwp_work_' +
str(level)]
if level > 0:
self.level0_warn(release_id, tm, level)
if '~cwp' + meta_str + '_part_' + \
str(level) not in tm and '~cwp_part_' + str(level) in tm:
tm['~cwp' +
meta_str +
'_part_' +
str(level)] = tm['~cwp_part_' +
str(level)]
if level > 0:
self.level0_warn(release_id, tm, level)
def create_work_levels(self, release_id, name_type, tracks, track, track_index,
track_meta, tm, meta_str, part_level, depth, width, common_len):
"""
For a group of tracks with common metadata in the title/level0 work, create the work structure
for that metadata, using the structure in the MB database
:param release_id:
:param name_type: title or work
:param tracks: {'track':[(track1, height1), (track2, height2), ...], 'work': [work1, work2,...],
'title': [title1, title2, ...], 'tracknumber': [tracknumber1, tracknumber2, ...]}
where height is the number of levels in total in the branch for that track (i.e. height 1 => work_0 & work_1)
:param track:
:param track_index: index of track in tracks
:param track_meta:
:param tm: track meta (dup?)
:param meta_str: string created from name_type
:param part_level: The level of the current item in the works hierarchy
:param depth: The number of levels below the current item
:param width: The number of children of the current item
:param common_len: length of the common text
:return:
"""
allow_repeats = True
write_log(
release_id,
'info',
"Use %s info for track: %s at level %s",
name_type,
track_meta,
part_level)
name = tracks[name_type][track_index]
if isinstance(name, list):
work = name[0][:common_len]
else:
work = name[:common_len]
work = work.rstrip(":,.;- ")
if self.options[track]["cwp_removewords_p"]:
removewords = self.options[track]["cwp_removewords_p"].split(
',')
else:
removewords = []
write_log(
release_id,
'info',
"Prefixes (in %s) = %s",
name_type,
removewords)
for prefix in removewords:
prefix2 = str(prefix).lower().rstrip()
if prefix2[0] != " ":
prefix2 = " " + prefix2
write_log(
release_id, 'info', "checking prefix %s", prefix2)
if work.lower().endswith(prefix2):
if len(prefix2) > 0:
work = work[:-len(prefix2)]
common_len = len(work)
work = work.rstrip(":,.;- ")
if work.lower() == prefix2.strip():
work = ''
common_len = 0
write_log(
release_id,
'info',
"work after prefix strip %s",
work)
write_log(release_id, 'info', "Prefixes checked")
tm['~cwp' + meta_str + '_work_' +
str(part_level)] = work
if part_level > 0 and name_type == "work":
write_log(
release_id,
'info',
'checking if %s is repeated name at part_level = %s',
work,
part_level)
write_log(release_id, 'info', 'lower work name is %s',
tm['~cwp' + meta_str + '_work_' + str(part_level - 1)])
# fill in missing names caused by no common string at lower levels
# count the missing levels and push the current name
# down to the lowest missing level
missing_levels = 0
fill_level = part_level - 1
while '~cwp' + meta_str + '_work_' + \
str(fill_level) not in tm:
missing_levels += 1
fill_level -= 1
if fill_level < 0:
break
write_log(
release_id,
'info',
'there is/are %s missing level(s)',
missing_levels)
if missing_levels > 0:
allow_repeats = True
for lev in range(
part_level - missing_levels, part_level):
if lev > 0: # not filled_lowest and lev > 0:
tm['~cwp' + meta_str +
'_work_' + str(lev)] = work
tm['~cwp' +
meta_str +
'_part_' +
str(lev - 1)] = self.strip_parent_from_work(track,
release_id,
interpret(tm['~cwp' + meta_str + '_work_'
+ str(lev - 1)]),
tm['~cwp' + meta_str + '_work_' + str(lev)],
lev - 1, False)[0]
else:
tm['~cwp' + meta_str + '_work_' + str(lev)] = tm['~cwp_work_' + str(lev)]
if missing_levels > 0:
write_log(release_id, 'info', 'lower work name is now %r', tm.getall(
'~cwp' + meta_str + '_work_' + str(part_level - 1)))
# now fix the repeated work name at this level
if work == tm['~cwp' + meta_str + '_work_' +
str(part_level - 1)] and not allow_repeats:
tm['~cwp' +
meta_str +
'_work_' +
str(part_level)] = tm['~cwp_work_' +
str(part_level)]
self.level0_warn(release_id, tm, part_level)
tm['~cwp' +
meta_str +
'_part_' +
str(part_level -
1)] = self.strip_parent_from_work(track,
release_id,
tm.getall('~cwp' + meta_str + '_work_' + str(part_level - 1)),
tm['~cwp' + meta_str + '_work_' + str(part_level)],
part_level - 1, False)[0]
if part_level == 1:
if isinstance(name, list):
movt = [x[common_len:].strip().lstrip(":,.;- ")
for x in name]
else:
movt = name[common_len:].strip().lstrip(":,.;- ")
write_log(
release_id, 'info', "%s - movt = %s", name_type, movt)
tm['~cwp' + meta_str + '_part_0'] = movt
write_log(
release_id,
'info',
"%s Work part_level = %s",
name_type,
part_level)
if name_type == 'title':
if '~cwp_title_work_' + str(part_level - 1) in tm and tm['~cwp_title_work_' + str(
part_level)] == tm['~cwp_title_work_' + str(part_level - 1)] and width == 1:
pass # don't count higher part-levels which are not distinct from lower ones
# when the parent work has only one child
else:
tm['~cwp_title_work_levels'] = depth
tm['~cwp_title_part_levels'] = part_level
write_log(
release_id,
'info',
"Set new metadata for %s OK",
name_type)
def level0_warn(self, release_id, tm, level):
"""
Issue warnings if inadequate level 0 data
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param tm:
:param level:
:return:
"""
write_log(
release_id,
'warning',
'Unable to use level 0 as work name source in level %s - using hierarchy instead',
level)
if self.WARNING:
self.append_tag(
release_id,
tm,
'~cwp_warning',
'5. Unable to use level 0 as work name source in level ' +
str(level) +
' - using hierarchy instead')
def set_metadata(
self,
release_id,
part_level,
workId,
parentId,
parent,
track):
"""
Set the names of works and parts
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param part_level:
:param workId:
:param parentId:
:param parent:
:param track:
:return:
"""
write_log(
release_id,
'debug',
"SETTING METADATA FOR TRACK = %r, parent = %s, part_level = %s",
track,
parent,
part_level)
tm = track.metadata
if parentId:
self.write_tags(release_id, track, tm, parentId)
self.make_annotations(release_id, track, parentId)
if 'annotations' in self.parts[workId]:
work_annotations = self.parts[workId]['annotations']
self.parts[workId]['stripped_annotations'] = work_annotations
else:
work_annotations = []
if 'annotations' in self.parts[parentId]:
parent_annotations = self.parts[parentId]['annotations']
else:
parent_annotations = []
if parent_annotations:
work_annotations = [
z for z in work_annotations if z not in parent_annotations]
self.parts[workId]['stripped_annotations'] = work_annotations
tm['~cwp_workid_' + str(part_level)] = parentId
tm['~cwp_work_' + str(part_level)] = parent
# maybe more than one work name
work = self.parts[workId]['name']
write_log(release_id, 'info', "Set work name to: %s", work)
works = []
# in case there is only one and it isn't in a list
if isinstance(work, str):
works.append(work)
else:
works = work[:]
stripped_works = []
for work in works:
extend = True
strip = self.strip_parent_from_work(
track, release_id, work, parent, part_level, extend, parentId, workId)
stripped_works.append(strip[0])
write_log(
release_id,
'info',
"Parent: %s, Stripped works = %s",
parent,
stripped_works)
# now == parent, after removing full_parent logic
full_parent = strip[1]
if full_parent != parent:
tm['~cwp_work_' +
str(part_level)] = full_parent.strip()
self.parts[parentId]['name'] = full_parent
if 'no_parent' in self.parts[parentId]:
if self.parts[parentId]['no_parent']:
tm['~cwp_work_top'] = full_parent.strip()
tm['~cwp_part_' + str(part_level - 1)] = stripped_works
self.parts[workId]['stripped_name'] = stripped_works
write_log(release_id, 'debug', "GOT TO END OF SET_METADATA")
def write_tags(self, release_id, track, tm, workId):
"""
write genre-related tags from internal variables
:param track:
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param tm: track metadata
:param workId: MBID of current work
:return: None - just writes tags
"""
options = self.options[track]
candidate_genres = []
if options['cwp_genres_use_folks'] and 'folks_genres' in self.parts[workId]:
candidate_genres += self.parts[workId]['folks_genres']
if options['cwp_genres_use_worktype'] and 'worktype_genres' in self.parts[workId]:
candidate_genres += self.parts[workId]['worktype_genres']
self.append_tag(
release_id,
tm,
'~cwp_worktype_genres',
self.parts[workId]['worktype_genres'])
self.append_tag(
release_id,
tm,
'~cwp_candidate_genres',
candidate_genres)
self.append_tag(release_id, tm, '~cwp_keys', self.parts[workId]['key'])
self.append_tag(release_id, tm, '~cwp_composed_dates',
self.parts[workId]['composed_dates'])
self.append_tag(release_id, tm, '~cwp_published_dates',
self.parts[workId]['published_dates'])
self.append_tag(release_id, tm, '~cwp_premiered_dates',
self.parts[workId]['premiered_dates'])
def make_annotations(self, release_id, track, wid):
"""
create an 'annotations' entry in the 'parts' dict, as dictated by options, from dates and keys
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param track: the current track
:param wid: the current work MBID
:return:
"""
write_log(
release_id,
'debug',
"Starting module %s",
'make_annotations')
options = self.options[track]
if options['cwp_workdate_include']:
if options['cwp_workdate_source_composed'] and 'composed_dates' in self.parts[wid] and self.parts[wid]['composed_dates']:
workdates = self.parts[wid]['composed_dates']
elif options['cwp_workdate_source_published'] and 'published_dates' in self.parts[wid] and self.parts[wid]['published_dates']:
workdates = self.parts[wid]['published_dates']
elif options['cwp_workdate_source_premiered'] and 'premiered_dates' in self.parts[wid] and self.parts[wid]['premiered_dates']:
workdates = self.parts[wid]['premiered_dates']
else:
workdates = []
else:
workdates = []
keys = []
if options['cwp_key_include'] and 'key' in self.parts[wid] and self.parts[wid]['key']:
keys = self.parts[wid]['key']
elif options['cwp_key_contingent_include'] and 'key' in self.parts[wid] and self.parts[wid]['key']\
and 'name' in self.parts[wid]:
write_log(
release_id,
'info',
'checking for key. keys = %s, names = %s',
self.parts[wid]['key'],
self.parts[wid]['name'])
# add all the parent names to the string for checking -
work_name = list_to_str(self.parts[wid]['name'])
work_chk = wid
while work_chk in self.works_cache:
parent_chk = tuple(self.works_cache[work_chk])
if parent_chk in self.parts and self.parts[parent_chk] and 'name' in self.parts[parent_chk] and self.parts[parent_chk]['name']:
parent_name = list_to_str(self.parts[parent_chk]['name'])
p_name_orig = self.parts[parent_chk]['name']
p_chk = self.parts[parent_chk]
work_name = parent_name + ': ' + work_name
work_chk = parent_chk
# now see if the key has been mentioned in the work or its parents
for key in self.parts[wid]['key']:
# if not any([key.lower() in x.lower() for x in
# str_to_list(work_name)]): # TODO remove
if not key.lower() in work_name.lower():
keys.append(key)
annotations = keys + workdates
if annotations:
self.parts[wid]['annotations'] = annotations
else:
if 'annotations' in self.parts[wid]:
del self.parts[wid]['annotations']
write_log(
release_id,
'info',
'make annotations has set id %s on track %s with annotation %s',
wid,
track,
annotations)
write_log(
release_id,
'debug',
"Ending module %s",
'make_annotations')
@staticmethod
def derive_from_title(release_id, track, title):
"""
Attempt to parse title to get components
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param track:
:param title:
:return:
"""
write_log(
release_id,
'info',
"DERIVING METADATA FROM TITLE for track: %s",
track)
tm = track.metadata
movt = title
work = ""
colons = title.count(": ")
inter_work = None
if '~cwp_part_levels' in tm:
part_levels = int(tm['~cwp_part_levels'])
if int(tm['~cwp_work_part_levels']
) > 0: # we have a work with movements
if colons > 0:
title_split = title.split(': ', 1)
title_rsplit = title.rsplit(': ', 1)
if part_levels >= colons:
work = title_rsplit[0]
movt = title_rsplit[1]
else:
work = title_split[0]
movt = title_split[1]
else:
# No works found so try and just get parts from title
if colons > 0:
title_split = title.rsplit(': ', 1)
work = title_split[0]
if colons > 1:
colon_ind = work.rfind(':')
inter_work = work[colon_ind + 1:].strip()
work = work[:colon_ind]
movt = title_split[1]
write_log(release_id, 'info', "Work %s, Movt %s", work, movt)
return work, movt, inter_work
def process_work_artists(
self,
release_id,
album,
track,
workIds,
tm,
count):
"""
Carry out the artist processing that needs to be done in the PartLevels class
as it requires XML lookups of the works
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:param track:
:param workIds:
:param tm:
:param count:
:return:
"""
if not self.options[track]['classical_extra_artists']:
write_log(
release_id,
'debug',
'Not processing work_artists as ExtraArtists not selected to be run')
return None
write_log(
release_id,
'debug',
'In process_work_artists for track: %s, workIds: %s',
track,
workIds)
write_log(
release_id,
'debug',
'In process_work_artists for track: %s, self.parts: %s',
track,
self.parts)
if workIds in self.parts and 'arrangers' in self.parts[workIds]:
write_log(
release_id,
'info',
'Arrangers = %s',
self.parts[workIds]['arrangers'])
set_work_artists(
self,
release_id,
album,
track,
self.parts[workIds]['arrangers'],
tm,
count)
if workIds in self.works_cache:
count += 1
self.process_work_artists(release_id, album, track, tuple(
self.works_cache[workIds]), tm, count)
#################################################
# SECTION 5 - Extend work metadata using titles #
#################################################
def extend_metadata(self, release_id, top_info, track, ref_height, depth):
"""
Combine MB work and title data according to user options
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param top_info:
:param track:
:param ref_height:
:param depth:
:return:
"""
write_log(release_id, 'debug', 'IN EXTEND_METADATA')
tm = track.metadata
options = self.options[track]
movementgroup = ()
if '~cwp_part_levels' not in tm:
write_log(
release_id,
'debug',
'NO PART LEVELS. Metadata = %s',
tm)
return None
part_levels = int(tm['~cwp_part_levels'])
write_log(
release_id,
'debug',
"Extending metadata for track: %s, ref_height: %s, depth: %s, part_levels: %s",
track,
ref_height,
depth,
part_levels)
write_log(release_id, 'info', "Metadata = %s", tm)
# previously: ref_height = work_part_levels - ref_level,
# where this ref-level is the level for the top-named work
# so ref_height is effectively the "single work album" indicator (1 or 0) -
# i.e. where all tracks are part of one work which is implicitly the album
# without there being a groupheading for it
ref_level = part_levels - ref_height
# work_ref_level = work_part_levels - ref_height # not currently used
# replace works and parts by those derived from the level 0 work, where
# required, available and appropriate, but only use work names based on
# level 0 text if it doesn't cause ambiguity
# before embellishing with partial / arrangement etc
vanilla_part = tm['~cwp_part_0']
# Fix text for arrangements, partials and medleys (Done here so that
# cache can be used)
if options['cwp_arrangements'] and options["cwp_arrangements_text"]:
for lev in range(
0,
ref_level): # top level will not be an arrangement else there would be a higher level
# needs to be a tuple to match
if '~cwp_workid_' + str(lev) in tm:
tup_id = tuple(str_to_list(tm['~cwp_workid_' + str(lev)]))
if 'arrangement' in self.parts[tup_id] and self.parts[tup_id]['arrangement']:
update_list = ['~cwp_work_', '~cwp_part_']
if options["cwp_level0_works"] and '~cwp_X0_work_' + \
str(lev) in tm:
update_list += ['~cwp_X0_work_', '~cwp_X0_part_']
for item in update_list:
tm[item + str(lev)] = options["cwp_arrangements_text"] + \
' ' + tm[item + str(lev)]
if options['cwp_partial'] and options["cwp_partial_text"]:
if '~cwp_workid_0' in tm:
work0_id = tuple(str_to_list(tm['~cwp_workid_0']))
if 'partial' in self.parts[work0_id] and self.parts[work0_id]['partial']:
update_list = ['~cwp_work_0', '~cwp_part_0']
if options["cwp_level0_works"] and '~cwp_X0_work_0' in tm:
update_list += ['~cwp_X0_work_0', '~cwp_X0_part_0']
for item in update_list:
meta_item = tm.getall(item)
if isinstance(
meta_item, list): # it should be a list as I think getall always returns a list
if meta_item == []:
meta_item.append(options["cwp_partial_text"])
else:
for ind, w in enumerate(meta_item):
meta_item[ind] = options["cwp_partial_text"] + ' ' + w
write_log(
release_id, 'info', 'now meta item is %s', meta_item)
tm[item] = meta_item
else:
tm[item] = options["cwp_partial_text"] + \
' ' + tm[item]
write_log(
release_id, 'info', 'meta item is not a list')
# fix "type 1" medley text
if options['cwp_medley']:
for lev in range(0, ref_level + 1):
if '~cwp_workid_' + str(lev) in tm:
tup_id = tuple(str_to_list(tm['~cwp_workid_' + str(lev)]))
if 'medley_list' in self.parts[tup_id] and self.parts[tup_id]['medley_list']:
medley_list = self.parts[tup_id]['medley_list']
tm['~cwp_work_' + str(lev)] += " (" + options["cwp_medley_text"] + \
': ' + ', '.join(medley_list) + ")"
if '~cwp_part_' + str(lev) in tm:
tm['~cwp_part_' + str(
lev)] = "(" + options["cwp_medley_text"] + ") " + tm['~cwp_part_' + str(lev)]
# add any annotations for dates and keys
if options['cwp_workdate_include'] or options['cwp_key_include'] or options['cwp_key_contingent_include']:
if options["cwp_titles"] and part_levels == 0:
# ~cwp_title_work_0 will not have been set, but need it to hold any annotations
tm['~cwp_title_work_0'] = tm['~cwp_title'] or tm['title']
for lev in range(0, part_levels + 1):
if '~cwp_workid_' + str(lev) in tm:
tup_id = tuple(str_to_list(tm['~cwp_workid_' + str(lev)]))
if 'annotations' in self.parts[tup_id]:
write_log(
release_id,
'info',
'in extend_metadata, annotations for id %s on track %s are %s',
tup_id,
track,
self.parts[tup_id]['annotations'])
tm['~cwp_work_' + str(lev)] += " (" + \
', '.join(self.parts[tup_id]['annotations']) + ")"
if options["cwp_level0_works"] and '~cwp_X0_work_' + \
str(lev) in tm:
tm['~cwp_X0_work_' + str(lev)] += " (" + ', '.join(
self.parts[tup_id]['annotations']) + ")"
if options["cwp_titles"] and '~cwp_title_work_' + \
str(lev) in tm:
tm['~cwp_title_work_' + str(lev)] += " (" + ', '.join(
self.parts[tup_id]['annotations']) + ")"
if lev < part_levels:
if 'stripped_annotations' in self.parts[tup_id]:
if self.parts[tup_id]['stripped_annotations']:
tm['~cwp_part_' + str(lev)] += " (" + ', '.join(
self.parts[tup_id]['stripped_annotations']) + ")"
if options["cwp_level0_works"] and '~cwp_X0_part_' + \
str(lev) in tm:
tm['~cwp_X0_part_' + str(lev)] += " (" + ', '.join(
self.parts[tup_id]['stripped_annotations']) + ")"
if options["cwp_titles"] and '~cwp_title_part_' + \
str(lev) in tm:
tm['~cwp_title_part' + str(lev)] += " (" + ', '.join(
self.parts[tup_id]['stripped_annotations']) + ")"
part = []
work = []
for level in range(0, part_levels):
part.append(tm['~cwp_part_' + str(level)])
work.append(tm['~cwp_work_' + str(level)])
work.append(tm['~cwp_work_' + str(part_levels)])
# Use level_0-derived names if applicable
if options["cwp_level0_works"]:
for level in range(0, part_levels + 1):
if '~cwp_X0_work_' + str(level) in tm:
work[level] = tm['~cwp_X0_work_' + str(level)]
else:
if level != 0:
work[level] = ''
if part and len(part) > level:
if '~cwp_X0_part_' + str(level) in tm:
part[level] = tm['~cwp_X0_part_' + str(level)]
else:
if level != 0:
part[level] = ''
# set up group heading and part
if part_levels > 0:
groupheading = work[1]
work_main = work[ref_level]
inter_work = None
work_titles = tm['~cwp_title_work_' + str(ref_level)]
if ref_level > 1:
for r in range(1, ref_level):
if inter_work:
inter_work = ': ' + inter_work
inter_work = part[r] + (inter_work or '')
groupheading = work[ref_level] + ':: ' + (inter_work or '')
else:
groupheading = work[0]
work_main = groupheading
inter_work = None
work_titles = None
# determine movement grouping (highest level that is not a collection)
if '~cwp_workid_top' in tm:
movementgroup = tuple(str_to_list(tm['~cwp_workid_top']))
n = part_levels
write_log(
release_id,
'debug',
"In extend. self.parts[%s]['is_collection']: %s",
movementgroup,
self.parts[movementgroup]['is_collection'])
while self.parts[movementgroup]['is_collection']:
n -= 1
if n < 0:
# shouldn't happen in theory as bottom level can't be a collection, but just in case...
break
if '~cwp_workid_' + str(n) in tm:
movementgroup = tuple(str_to_list(tm['~cwp_workid_' + str(n)]))
else:
break
# set part text (initially)
if part:
part_main = part[0]
else:
part_main = work[0]
tm['~cwp_part'] = part_main
# fix medley text for "type 2" medleys
type2_medley = False
if self.parts[tuple(str_to_list(tm['~cwp_workid_0']))
]['medley'] and options['cwp_medley']:
if options["cwp_medley_text"]:
if part_levels > 0:
medleyheading = groupheading + ':: ' + part[0]
else:
medleyheading = groupheading
groupheading = medleyheading + \
' (' + options["cwp_medley_text"] + ')'
type2_medley = True
tm['~cwp_groupheading'] = groupheading
tm['~cwp_work'] = work_main
tm['~cwp_inter_work'] = inter_work
tm['~cwp_title_work'] = work_titles
write_log(
release_id,
'debug',
"Groupheading set to: %s",
groupheading)
# extend group heading from title metadata
if groupheading:
ext_groupheading = groupheading
title_groupheading = None
ext_work = work_main
ext_inter_work = inter_work
inter_title_work = ""
if '~cwp_title_work_levels' in tm:
title_depth = int(tm['~cwp_title_work_levels'])
write_log(
release_id,
'info',
"Title_depth: %s",
title_depth)
diff_work = [""] * ref_level
diff_part = [""] * ref_level
title_tag = [""]
# level 0 work for title # was 'x' # to avoid errors, reset
# before used
tw_str_lower = 'title'
max_d = min(ref_level, title_depth) + 1
for d in range(1, max_d):
tw_str = '~cwp_title_work_' + str(d)
write_log(release_id, 'info', "TW_STR = %s", tw_str)
if tw_str in tm:
title_tag.append(tm[tw_str])
title_work = title_tag[d]
work_main = ''
for w in range(d, ref_level + 1):
work_main += (work[w] + ' ')
diff_work[d - 1] = self.diff_pair(
release_id, track, tm, work_main, title_work)
if diff_work[d - 1]:
diff_work[d - 1] = diff_work[d - 1].strip('.;:-,')
if diff_work[d - 1] == '…':
diff_work[d - 1] = ''
if d > 1 and tw_str_lower in tm:
title_part = self.strip_parent_from_work(
track, release_id, tm[tw_str_lower], tm[tw_str], 0, False)[0]
if title_part:
title_part = title_part.strip(' .;:-,')
tm['~cwp_title_part_' +
str(d - 1)] = title_part
part_n = part[d - 1]
diff_part[d - 1] = self.diff_pair(
release_id, track, tm, part_n, title_part) or ""
if diff_part[d - 1] == '…':
diff_part[d - 1] = ''
else:
title_tag.append('')
tw_str_lower = tw_str
# remove duplicate items at lower levels in diff_work:
for w in range(ref_level - 2, -1, -1):
for higher in range(1, ref_level - w):
if diff_work[w] and diff_work[w + higher]:
diff_work[w] = diff_work[w].replace(
diff_work[w + higher], '').strip(' .;:-,\u2026')
# if diff_work[w] == '…':
# diff_work[w] = ''
write_log(
release_id,
'info',
"diff list for works: %s",
diff_work)
write_log(
release_id,
'info',
"diff list for parts: %s",
diff_part)
if not diff_work or len(diff_work) == 0:
if part_levels > 0:
ext_groupheading = groupheading
else:
write_log(
release_id,
'debug',
"Now calc extended groupheading...")
write_log(
release_id,
'info',
"depth = %s, ref_level = %s, title_depth = %s",
depth,
ref_level,
title_depth)
write_log(
release_id,
'info',
"diff_work = %s, diff_part = %s",
diff_work,
diff_part)
# remove duplications:
for lev in range(1, ref_level):
for diff_list in [diff_work, diff_part]:
if diff_list[lev] and diff_list[lev - 1]:
diff_list[lev - 1] = self.diff_pair(
release_id, track, tm, diff_list[lev], diff_list[lev - 1])
if diff_list[lev - 1] == '…':
diff_list[lev - 1] = ''
write_log(
release_id,
'info',
"Removed duplication. Revised diff_work = %s, diff_part = %s",
diff_work,
diff_part)
if part_levels > 0 and depth >= 1:
addn_work = []
addn_part = []
for stripped_work in diff_work:
if stripped_work:
write_log(
release_id, 'info', "Stripped work = %s", stripped_work)
addn_work.append(" {" + stripped_work + "}")
else:
addn_work.append("")
for stripped_part in diff_part:
if stripped_part and stripped_part != "":
write_log(release_id, 'info', "Stripped part = %s", stripped_part)
addn_part.append(" {" + stripped_part + "}")
else:
addn_part.append("")
write_log(
release_id,
'info',
"addn_work = %s, addn_part = %s",
addn_work,
addn_part)
ext_groupheading = work[1] + addn_work[0]
ext_work = work[ref_level] + addn_work[ref_level - 1]
ext_inter_work = ""
inter_title_work = ""
title_groupheading = tm['~cwp_title_work_1']
if ref_level > 1:
for r in range(1, ref_level):
if ext_inter_work:
ext_inter_work = ': ' + ext_inter_work
ext_inter_work = part[r] + \
addn_work[r - 1] + ext_inter_work
ext_groupheading = work[ref_level] + \
addn_work[ref_level - 1] + ':: ' + ext_inter_work
if title_depth > 1 and ref_level > 1:
for r in range(1, min(title_depth, ref_level)):
if inter_title_work:
inter_title_work = ': ' + inter_title_work
inter_title_work = tm['~cwp_title_part_' +
str(r)] + inter_title_work
title_groupheading = tm['~cwp_title_work_' + str(
min(title_depth, ref_level))] + ':: ' + inter_title_work
else:
ext_groupheading = groupheading # title will be in part
ext_work = work_main
ext_inter_work = inter_work
inter_title_work = ""
write_log(release_id, 'debug', ".... ext_groupheading done")
if ext_groupheading:
write_log(
release_id,
'info',
"EXTENDED GROUPHEADING: %s",
ext_groupheading)
tm['~cwp_extended_groupheading'] = ext_groupheading
tm['~cwp_extended_work'] = ext_work
if ext_inter_work:
tm['~cwp_extended_inter_work'] = ext_inter_work
if inter_title_work:
tm['~cwp_inter_title_work'] = inter_title_work
if title_groupheading:
tm['~cwp_title_groupheading'] = title_groupheading
write_log(
release_id,
'info',
"title_groupheading = %s",
title_groupheading)
# extend part from title metadata
write_log(
release_id,
'debug',
"NOW EXTEND PART...(part = %s)",
part_main)
if part_main:
if '~cwp_title_part_0' in tm:
movement = tm['~cwp_title_part_0']
else:
movement = tm['~cwp_title_part_0'] or tm['~cwp_title'] or tm['title']
if '~cwp_extended_groupheading' in tm:
work_compare = tm['~cwp_extended_groupheading'] + \
': ' + part_main
elif '~cwp_work_1' in tm:
work_compare = work[1] + ': ' + part_main
else:
work_compare = work[0]
diff = self.diff_pair(
release_id, track, tm, work_compare, movement)
# compare with the fullest possible work name, not the stripped one
# - to maximise the duplication elimination
reverse_diff = self.diff_pair(
release_id, track, tm, movement, vanilla_part)
# for the reverse comparison use the part name without any work details or annotation
if diff and reverse_diff and self.parts[tuple(str_to_list(tm['~cwp_workid_0']))]['partial']:
diff = movement
# for partial tracks, do not eliminate the title text as it is
# frequently deliberately a component of the the overall work txt
# (unless it is identical)
fill_part = options['cwp_fill_part']
# To fill part with title text if it
# would otherwise have no text other than arrangement or partial
# annotations
if not diff and not vanilla_part and part_levels > 0 and fill_part:
# In other words the movement will have no text other than
# arrangement or partial annotations
diff = movement
write_log(release_id, 'info', "DIFF PART - MOVT. ti =%s", diff)
write_log(release_id,
'info',
'medley indicator for %s is %s',
tm['~cwp_workid_0'],
self.parts[tuple(str_to_list(tm['~cwp_workid_0']))]['medley'])
if type2_medley:
tm['~cwp_extended_part'] = "{" + movement + "}"
else:
if diff:
tm['~cwp_extended_part'] = part_main + \
" {" + diff.strip() + "}"
else:
tm['~cwp_extended_part'] = part_main
if part_levels == 0:
if tm['~cwp_extended_groupheading']:
del tm['~cwp_extended_groupheading']
# remove unwanted groupheadings (needed them up to now for adding
# extensions)
if '~cwp_groupheading' in tm and tm['~cwp_groupheading'] == tm['~cwp_part']:
del tm['~cwp_groupheading']
if '~cwp_title_groupheading' in tm and tm['~cwp_title_groupheading'] == tm['~cwp_title_part']:
del tm['~cwp_title_groupheading']
# clean up groupheadings (may be stray separators if level 0 or title
# options used)
if '~cwp_groupheading' in tm:
tm['~cwp_groupheading'] = tm['~cwp_groupheading'].strip(
':').strip(
options['cwp_single_work_sep']).strip(
options['cwp_multi_work_sep'])
if '~cwp_extended_groupheading' in tm:
tm['~cwp_extended_groupheading'] = tm['~cwp_extended_groupheading'].strip(
':').strip(
options['cwp_single_work_sep']).strip(
options['cwp_multi_work_sep'])
if '~cwp_title_groupheading' in tm:
tm['~cwp_title_groupheading'] = tm['~cwp_title_groupheading'].strip(
':').strip(
options['cwp_single_work_sep']).strip(
options['cwp_multi_work_sep'])
write_log(release_id, 'debug', "....done")
return movementgroup
##########################################################
# SECTION 6- Write metadata to tags according to options #
##########################################################
def publish_metadata(self, release_id, album, track, movement_info={}):
"""
Write out the metadata according to user options
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param album:
:param track:
:param movement_info: format is {'movement-group': movementgroup, 'movement-number': movementnumber}
:return:
"""
write_log(release_id, 'debug', "IN PUBLISH METADATA for %s", track)
options = self.options[track]
tm = track.metadata
tm['~cwp_version'] = PLUGIN_VERSION
# set movement grouping tags (hidden vars)
if movement_info:
movementtotal = self.parts[tuple(movement_info['movement-group'])]['movement-total']
if movementtotal > 1:
tm['~cwp_movt_num'] = movement_info['movement-number']
tm['~cwp_movt_tot'] = movementtotal
# album composers needed by map_tags (set in set_work_artists)
if 'composer_lastnames' in self.album_artists[album]:
last_names = seq_last_names(self, album)
self.append_tag(
release_id,
tm,
'~cea_album_composer_lastnames',
last_names)
write_log(release_id, 'info', "Check options")
if options["cwp_titles"]:
write_log(release_id, 'info', "titles")
part = tm['~cwp_title_part_0'] or tm['~cwp_title_work_0']or tm['~cwp_title'] or tm['title']
# for multi-level work display
groupheading = tm['~cwp_title_groupheading'] or ""
# for single-level work display
work = tm['~cwp_title_work'] or ""
inter_work = tm['~cwp_inter_title_work'] or ""
elif options["cwp_works"]:
write_log(release_id, 'info', "works")
part = tm['~cwp_part']
groupheading = tm['~cwp_groupheading'] or ""
work = tm['~cwp_work'] or ""
inter_work = tm['~cwp_inter_work'] or ""
else:
# options["cwp_extended"]
write_log(release_id, 'info', "extended")
part = tm['~cwp_extended_part']
groupheading = tm['~cwp_extended_groupheading'] or ""
work = tm['~cwp_extended_work'] or ""
inter_work = tm['~cwp_extended_inter_work'] or ""
write_log(release_id, 'info', "Done options")
p1 = RE_ROMANS_AT_START
# Matches positive integers with punctuation
p2 = re.compile(r'^\W*\d+[.):-]')
movt = part
for _ in range(
0, 5): # in case of multiple levels
movt = p2.sub('', p1.sub('', movt)).strip()
write_log(release_id, 'info', "Done movt")
movt_inc_tags = options["cwp_movt_tag_inc"].split(",")
movt_inc_tags = [x.strip(' ') for x in movt_inc_tags]
movt_exc_tags = options["cwp_movt_tag_exc"].split(",")
movt_exc_tags = [x.strip(' ') for x in movt_exc_tags]
movt_inc_1_tags = options["cwp_movt_tag_inc1"].split(",")
movt_inc_1_tags = [x.strip(' ') for x in movt_inc_1_tags]
movt_exc_1_tags = options["cwp_movt_tag_exc1"].split(",")
movt_exc_1_tags = [x.strip(' ') for x in movt_exc_1_tags]
movt_no_tags = options["cwp_movt_no_tag"].split(",")
movt_no_tags = [x.strip(' ') for x in movt_no_tags]
movt_no_sep = options["cwp_movt_no_sep"]
movt_tot_tags = options["cwp_movt_tot_tag"].split(",")
movt_tot_tags = [x.strip(' ') for x in movt_tot_tags]
gh_tags = options["cwp_work_tag_multi"].split(",")
gh_tags = [x.strip(' ') for x in gh_tags]
gh_sep = options["cwp_multi_work_sep"]
work_tags = options["cwp_work_tag_single"].split(",")
work_tags = [x.strip(' ') for x in work_tags]
work_sep = options["cwp_single_work_sep"]
top_tags = options["cwp_top_tag"].split(",")
top_tags = [x.strip(' ') for x in top_tags]
write_log(
release_id,
'info',
"Done splits. gh_tags: %s, work_tags: %s, movt_inc_tags: %s, movt_exc_tags: %s, movt_no_tags: %s",
gh_tags,
work_tags,
movt_inc_tags,
movt_exc_tags,
movt_no_tags)
for tag in gh_tags + work_tags + movt_inc_tags + movt_exc_tags + movt_no_tags:
tm[tag] = ""
for tag in gh_tags:
if tag in movt_inc_tags + movt_exc_tags + movt_no_tags:
self.append_tag(release_id, tm, tag, groupheading, gh_sep)
else:
self.append_tag(release_id, tm, tag, groupheading)
for tag in work_tags:
if tag in movt_inc_1_tags + movt_exc_1_tags + movt_no_tags:
self.append_tag(release_id, tm, tag, work, work_sep)
else:
self.append_tag(release_id, tm, tag, work)
if '~cwp_part_levels' in tm and int(tm['~cwp_part_levels']) > 0:
self.append_tag(
release_id,
tm,
'show work movement',
'1') # original tag for iTunes, kept for backwards compatibility
self.append_tag(
release_id,
tm,
'showmovement',
'1') # new tag for iTunes & MusicBee, consistent with Picard tag docs
for tag in top_tags:
if '~cwp_work_top' in tm:
self.append_tag(release_id, tm, tag, tm['~cwp_work_top'])
if '~cwp_movt_num' in tm and len(tm['~cwp_movt_num']) > 0:
movt_num_punc = tm['~cwp_movt_num'] + movt_no_sep + ' '
else:
movt_num_punc = ''
for tag in movt_no_tags:
if tag not in movt_inc_tags + movt_exc_tags + movt_inc_1_tags + movt_exc_1_tags:
self.append_tag(release_id, tm, tag, tm['~cwp_movt_num'])
for tag in movt_tot_tags:
self.append_tag(release_id, tm, tag, tm['~cwp_movt_tot'])
for tag in movt_exc_tags:
if tag in movt_no_tags:
movt = movt_num_punc + movt
self.append_tag(release_id, tm, tag, movt)
for tag in movt_inc_tags:
if tag in movt_no_tags:
part = movt_num_punc + part
self.append_tag(release_id, tm, tag, part)
for tag in movt_inc_1_tags + movt_exc_1_tags:
if tag in movt_inc_1_tags:
pt = part
else:
pt = movt
if tag in movt_no_tags:
pt = movt_num_punc + pt
if inter_work and inter_work != "":
if tag in movt_exc_tags + movt_inc_tags and tag != "":
write_log(
release_id,
'warning',
"Tag %s will have multiple contents",
tag)
if self.WARNING:
self.append_tag(release_id, tm, '~cwp_warning', '6. Tag ' +
tag +
' has multiple contents')
self.append_tag(
release_id,
tm,
tag,
inter_work +
work_sep +
" " +
pt)
else:
self.append_tag(release_id, tm, tag, pt)
for tag in movt_exc_tags + movt_inc_tags + movt_exc_1_tags + movt_inc_1_tags:
if tag in movt_no_tags:
# i.e treat as one item, not multiple
tm[tag] = "".join(re.split('|'.join(self.SEPARATORS), tm[tag]))
# write "SongKong" tags
if options['cwp_write_sk']:
write_log(release_id, 'debug', "Writing SongKong work tags")
if '~cwp_part_levels' in tm:
part_levels = int(tm['~cwp_part_levels'])
for n in range(0, part_levels + 1):
if '~cwp_work_' + \
str(n) in tm and '~cwp_workid_' + str(n) in tm:
source = tm['~cwp_work_' + str(n)]
source_id = list(
tuple(str_to_list(tm['~cwp_workid_' + str(n)])))
if n == 0:
self.append_tag(
release_id, tm, 'musicbrainz_work_composition', source)
for source_id_item in source_id:
self.append_tag(
release_id, tm, 'musicbrainz_work_composition_id', source_id_item)
if n == part_levels:
self.append_tag(
release_id, tm, 'musicbrainz_work', source)
if 'musicbrainz_workid' in tm:
del tm['musicbrainz_workid']
# Delete the Picard version of this tag before
# replacing it with the SongKong version
for source_id_item in source_id:
self.append_tag(
release_id, tm, 'musicbrainz_workid', source_id_item)
if n != 0 and n != part_levels:
self.append_tag(
release_id, tm, 'musicbrainz_work_part_level' + str(n), source)
for source_id_item in source_id:
self.append_tag(
release_id,
tm,
'musicbrainz_work_part_level' +
str(n) +
'_id',
source_id_item)
# carry out tag mapping
tm['~cea_works_complete'] = "Y"
map_tags(options, release_id, album, tm)
write_log(release_id, 'debug', "Published metadata for %s", track)
if options['cwp_options_tag'] != "":
self.cwp_options = collections.defaultdict(
lambda: collections.defaultdict(dict))
for opt in plugin_options('workparts') + plugin_options('genres'):
if 'name' in opt:
if 'value' in opt:
if options[opt['option']]:
self.cwp_options['Classical Extras']['Works options'][opt['name']] = opt['value']
else:
self.cwp_options['Classical Extras']['Works options'][opt['name']
] = options[opt['option']]
write_log(release_id, 'info', "Options %s", self.cwp_options)
if options['ce_version_tag'] and options['ce_version_tag'] != "":
self.append_tag(release_id, tm, options['ce_version_tag'], str(
'Version ' + tm['~cwp_version'] + ' of Classical Extras'))
if options['cwp_options_tag'] and options['cwp_options_tag'] != "":
self.append_tag(release_id, tm, options['cwp_options_tag'] +
':workparts_options', json.loads(
json.dumps(
self.cwp_options)))
if self.ERROR and "~cwp_error" in tm:
for error in str_to_list(tm['~cwp_error']):
code = error[0]
self.append_tag(release_id, tm, '001_errors:' + code, error)
if self.WARNING and "~cwp_warning" in tm:
for warning in str_to_list(tm['~cwp_warning']):
wcode = warning[0]
self.append_tag(release_id, tm, '002_warnings:' + wcode, warning)
def append_tag(self, release_id, tm, tag, source, sep=None):
"""
pass to main append routine
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param tm:
:param tag:
:param source:
:param sep: separators may be used to split string into list on appending
:return:
"""
write_log(
release_id,
'info',
"In append_tag (Work parts). tag = %s, source = %s, sep =%s",
tag,
source,
sep)
append_tag(release_id, tm, tag, source, self.SEPARATORS)
write_log(
release_id,
'info',
"Appended. Resulting contents of tag: %s are: %s",
tag,
tm[tag])
################################################
# SECTION 7 - Common string handling functions #
################################################
def strip_parent_from_work(
self,
track,
release_id,
work,
parent,
part_level,
extend,
parentId=None,
workId=None):
"""
Remove common text
:param track:
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param work: could be a list of works, all of which require stripping
:param parent:
:param part_level:
:param extend:
:param parentId:
:param workId:
:return:
"""
# extend=True is used [ NO LONGER to find "full_parent" names] + (with parentId)
# to trigger recursion if unable to strip parent name from work and also to look for common subsequences
# extend=False is used when this routine is called for other purposes
# than strict work: parent relationships
options = self.options[track]
write_log(
release_id,
'debug',
"STRIPPING HIGHER LEVEL WORK TEXT FROM PART NAMES")
write_log(
release_id,
'info',
'PARAMS: WORK = %r, PARENT = %s, PART_LEVEL = %s, EXTEND= %s',
work,
parent,
part_level,
extend)
if isinstance(work, list):
result = []
for w, work_item in enumerate(work):
if workId and isinstance(workId, list):
sub_workId = workId[w]
else:
sub_workId = workId
result.append(
self.strip_parent_from_work(
track,
release_id,
work_item,
parent,
part_level,
extend,
parentId,
sub_workId)[0])
return result, parent
if not isinstance(parent, str):
# in case it is a list - make sure it is a string
parent = '; '.join(parent)
if not isinstance(work, str):
work = '; '.join(work)
# replace any punctuation or numbers, with a space (to remove any
# inconsistent punctuation and numbering) - (?u) specifies the
# re.UNICODE flag in sub
clean_parent = re.sub("(?u)[\W]", ' ', parent)
# now allow the spaces to be filled with up to 2 non-letters
pattern_parent = clean_parent.replace(" ", "\W{0,2}")
pattern_parent = "(^|.*?\s)(\W*" + pattern_parent + "\W?)(.*)"
# (removed previous alternative pattern for extend=true, owing to catastrophic backtracking)
write_log(
release_id,
'info',
"Pattern parent: %s, Work: %s",
pattern_parent,
work)
p = re.compile(pattern_parent, re.IGNORECASE | re.UNICODE)
m = p.search(work)
if m:
write_log(release_id, 'info', "Matched...")
if m.group(1):
stripped_work = m.group(1) + u"\u2026" + m.group(3)
else:
stripped_work = m.group(3)
# may not have a full work name in the parent (missing op. no.
# etc.)
stripped_work = stripped_work.lstrip(":;,.- ")
else:
write_log(release_id, 'info', "No match...")
stripped_work = work
if extend and options['cwp_common_chars'] > 0:
# try stripping out a common substring (multiple times until
# nothing more stripped)
prev_stripped_work = ''
counter = 1
while prev_stripped_work != stripped_work:
if counter > 20:
break # in case something went awry
prev_stripped_work = stripped_work
parent_tuples = self.listify(release_id, track, parent)
parent_words = parent_tuples['s_tuple']
clean_parent_words = list(parent_tuples['s_test_tuple'])
for w, word in enumerate(clean_parent_words):
clean_parent_words[w] = self.boil(release_id, word)
work_tuples = self.listify(
release_id, track, stripped_work)
work_words = work_tuples['s_tuple']
clean_work_words = list(work_tuples['s_test_tuple'])
for w, word in enumerate(clean_work_words):
clean_work_words[w] = self.boil(release_id, word)
common_dets = longest_common_substring(
clean_work_words, clean_parent_words)
# this is actually a list, not a string, since list
# arguments were supplied
common_seq = common_dets['string']
seq_length = common_dets['length']
seq_start = common_dets['start']
# the original items (before 'cleaning')
full_common_seq = [
x.group() for x in work_words[seq_start:seq_start + seq_length]]
# number of words in common_seq
full_seq_length = sum([len(x.split())
for x in full_common_seq])
write_log(
release_id,
'info',
'Checking common sequence between parent and work, iteration %s ... parent_words = %s',
counter,
parent_words)
write_log(
release_id,
'info',
'... longest common sequence = %s',
common_seq)
if full_seq_length > 0:
potential_stripped_work = stripped_work
if seq_start > 0:
ellipsis = ' ' + u"\u2026" + ' '
else:
ellipsis = ''
if counter > 1:
potential_stripped_work = stripped_work.rstrip(
' :,-\u2026')
potential_stripped_work = potential_stripped_work.replace(
'(\u2026)', '').rstrip()
potential_stripped_work = potential_stripped_work[:work_words[seq_start].start(
)] + ellipsis + potential_stripped_work[work_words[seq_start + seq_length - 1].end():]
potential_stripped_work = potential_stripped_work.lstrip(
' :,-')
potential_stripped_work = re.sub(
r'(\W*…\W*)(\W*…\W*)', ' … ', potential_stripped_work)
potential_stripped_work = strip_excess_punctuation(
potential_stripped_work)
if full_seq_length >= options['cwp_common_chars'] \
or potential_stripped_work == '' and options['cwp_allow_empty_parts']:
# Make sure it is more than the required min (it will be > 0 anyway)
# unless a full strip will result anyway (and blank
# part names are allowed)
stripped_work = potential_stripped_work
if not stripped_work or stripped_work == '':
if workId and \
('arrangement' in self.parts[workId] and self.parts[workId]['arrangement']
and options['cwp_arrangements'] and options['cwp_arrangements_text']) \
or ('partial' in self.parts[workId] and self.parts[workId]['partial']
and options['cwp_partial'] and options['cwp_partial_text']) \
and options['cwp_allow_empty_parts']:
pass
else:
stripped_work = prev_stripped_work # do not allow empty parts
counter += 1
stripped_work = strip_excess_punctuation(stripped_work)
write_log(
release_id,
'info',
'stripped_work = %s',
stripped_work)
if extend and parentId and parentId in self.works_cache:
write_log(
release_id,
'info',
"Looking for match at next level up")
grandparentIds = tuple(self.works_cache[parentId])
grandparent = self.parts[grandparentIds]['name']
stripped_work = self.strip_parent_from_work(
track,
release_id,
stripped_work,
grandparent,
part_level,
True,
grandparentIds,
workId)[0]
write_log(
release_id,
'info',
"Finished strip_parent_from_work, Work: %s",
work)
write_log(release_id, 'info', "Stripped work: %s", stripped_work)
# Changed full_parent to parent after removal of 'extend' logic above
stripped_work = strip_excess_punctuation(stripped_work)
write_log(release_id, 'info', "Stripped work after punctuation removal: %s", stripped_work)
return stripped_work, parent
def diff_pair(
self,
release_id,
track,
tm,
mb_item,
title_item,
remove_numbers=True):
"""
Removes common text (or synonyms) from title item
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param track:
:param tm:
:param mb_item:
:param title_item:
:param remove_numbers: remove movement numbers when comparing (not currently called with False by anything)
:return: Reduced title item
"""
write_log(release_id, 'debug', "Inside DIFF_PAIR")
mb = mb_item.strip()
write_log(release_id, 'info', "mb = %s", mb)
write_log(release_id, 'info', "title_item = %s", title_item)
if not mb:
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
None)
return None
ti = title_item.strip(" :;-.,")
if ti.count('"') == 1:
ti = ti.strip('"')
if ti.count("'") == 1:
ti = ti.strip("'")
write_log(release_id, 'info', "ti (amended) = %s", ti)
if not ti:
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
None)
return None
if self.options[track]["cwp_removewords_p"]:
removewords = self.options[track]["cwp_removewords_p"].split(',')
else:
removewords = []
write_log(release_id, 'info', "Prefixes = %s", removewords)
# remove numbers, roman numerals, part etc and punctuation from the
# start
write_log(release_id, 'info', "checking prefixes")
found_prefix = True
i = 0
while found_prefix:
if i > 20:
break # safety valve
found_prefix = False
for prefix in removewords:
if prefix[0] != " ":
prefix2 = str(prefix).lower().lstrip()
write_log(
release_id, 'info', "checking prefix %s", prefix2)
if mb.lower().startswith(prefix2):
found_prefix = True
mb = mb[len(prefix2):]
if ti.lower().startswith(prefix2):
found_prefix = True
ti = ti[len(prefix2):]
mb = mb.strip()
ti = ti.strip()
i += 1
write_log(
release_id,
'info',
"pairs after prefix strip iteration %s. mb = %s, ti = %s",
i,
mb,
ti)
write_log(release_id, 'info', "Prefixes checked")
# replacements
replacements = self.replacements[track]
write_log(release_id, 'info', "Replacement: %s", replacements)
for tup in replacements:
for ind in range(0, len(tup) - 1):
ti = re.sub(tup[ind], tup[-1], ti, flags=re.IGNORECASE)
write_log(
release_id,
'debug',
'Looking for any new words in the title')
write_log(
release_id,
'info',
"Check before splitting: mb = %s, ti = %s",
mb,
ti)
ti_tuples = self.listify(release_id, track, ti)
ti_tuple = ti_tuples['s_tuple']
ti_test_tuple = ti_tuples['s_test_tuple']
mb_tuples = self.listify(release_id, track, mb)
mb_test_tuple = mb_tuples['s_test_tuple']
write_log(
release_id,
'info',
"Check after splitting: mb_test = %s, ti = %s, ti_test = %s",
mb_test_tuple,
ti_tuple,
ti_test_tuple)
ti_stencil = self.stencil(release_id, ti_tuple, ti)
ti_list = ti_stencil['match list']
ti_list_punc = ti_stencil['gap list']
ti_test_list = list(ti_test_tuple)
if ti_stencil['dummy']:
# to deal with case where stencil has added a dummy item at the
# start
ti_test_list.insert(0, '')
write_log(release_id, 'info', 'ti_test_list = %r', ti_test_list)
# zip is an iterable, not a list in Python 3, so make it re-usable
ti_zip_list = list(zip(ti_list, ti_list_punc))
# len(ti_list) should be = len(ti_test_list) as only difference should
# be synonyms which are each one 'word'
# However, because of the grouping of some words via regex, it is possible that inconsistencies might arise
# Therefore, there is a test here to check for equality and produce an
# error message (but continue processing)
if len(ti_list) != len(ti_test_list):
write_log(
release_id,
'error',
'Mismatch in title list after canonization/synonymization')
write_log(
release_id,
'error',
'Orig. title list = %r. Test list = %r',
ti_list,
ti_test_list)
# mb_test_tuple = self.listify(release_id, track, mb_test)
mb_list2 = list(mb_test_tuple)
for index, mb_bit2 in enumerate(mb_list2):
mb_list2[index] = self.boil(release_id, mb_bit2)
write_log(
release_id,
'info',
"mb_list2[%s] = %s",
index,
mb_list2[index])
ti_new = []
ti_rich_list = []
for i, ti_bit_test in enumerate(ti_test_list):
if i <= len(ti_list) - 1:
ti_bit = ti_zip_list[i]
# NB ti_bit is a tuple where the word (1st item) is grouped
# with its following punctuation (2nd item)
else:
ti_bit = ('', '')
write_log(
release_id,
'info',
"i = %s, ti_bit_test = %s, ti_bit = %s",
i,
ti_bit_test,
ti_bit)
ti_rich_list.append((ti_bit, True))
# Boolean to indicate whether ti_bit is a new word
if ti_bit_test == '':
ti_rich_list[i] = (ti_bit, False)
else:
if self.boil(release_id, ti_bit_test) in mb_list2:
ti_rich_list[i] = (ti_bit, False)
if remove_numbers: # Only remove numbers at the start if they are not new items
p0 = re.compile(r'\b\w+\b')
p1 = RE_ROMANS
p2 = re.compile(r'^\d+') # Matches positive integers
starts_with_numeral = True
while starts_with_numeral:
starts_with_numeral = False
if ti_rich_list and p0.match(ti_rich_list[0][0][0]):
start_word = p0.match(ti_rich_list[0][0][0]).group()
if p1.match(start_word) or p2.match(start_word):
if not ti_rich_list[0][1]:
starts_with_numeral = True
ti_rich_list.pop(0)
ti_test_list.pop(0)
write_log(
release_id,
'info',
"ti_rich_list before removing singletons = %s. length = %s",
ti_rich_list,
len(ti_rich_list))
s = 0
index = 0
change = ()
for i, (t, n) in enumerate(ti_rich_list):
if n:
s += 1
index = i
change = t # NB this is a tuple
p = self.options[track]["cwp_proximity"]
ep = self.options[track]["cwp_end_proximity"]
# NB these may be modified later
if s == 1:
if 0 < index < len(ti_rich_list) - 1:
# ignore singleton new words in middle of title unless they are
# within "cwp_end_proximity" from the start or end
write_log(
release_id, 'info', 'item length is %s', len(
change[0].split()))
# also make sure that the item is just one word before
# eliminating
if ep < index < len(ti_rich_list) - ep - \
1 and len(change[0].split()) == 1:
ti_rich_list[index] = (change, False)
s = 0
# remove prepositions
write_log(
release_id,
'info',
"ti_rich_list before removing prepositions = %s. length = %s",
ti_rich_list,
len(ti_rich_list))
if self.options[track]["cwp_prepositions"]:
prepositions_fat = self.options[track]["cwp_prepositions"].split(
',')
prepositions = [w.strip() for w in prepositions_fat]
for i, ti_bit_test in enumerate(
reversed(ti_test_list)): # Need to reverse it to check later prepositions first
if ti_bit_test.lower().strip() in prepositions:
# NB i is counting up while traversing the list backwards
j = len(ti_rich_list) - i - 1
if i == 0 or not ti_rich_list[j + 1][1]:
# Don't make it false if it is preceded by a
# non-preposition new word
if not (j > 0 and ti_rich_list[j -
1][1] and ti_test_list[j -
1].lower() not in prepositions):
ti_rich_list[j] = (ti_rich_list[j][0], False)
# create comparison for later usage
compare_string = ''
for item in ti_rich_list:
if item[1]:
compare_string += item[0][0]
ti_compare = self.boil(release_id, compare_string)
compare_length = len(ti_compare)
write_log(
release_id,
'info',
"ti_rich_list before gapping (True indicates a word in title not in MB work) = %s. length = %s",
ti_rich_list,
len(ti_rich_list))
if s > 0:
d = p - ep
start = True # To keep track of new words at the start of the title
for i, (ti_bit, new) in enumerate(ti_rich_list):
if not new:
write_log(
release_id,
'info',
"item(i = %s) val = %s - not new. proximity param = %s, end_proximity param = %s",
i,
ti_bit,
p,
ep)
if start:
prox_test = ep
else:
prox_test = p
if prox_test > 0:
for j in range(0, prox_test + 1):
write_log(release_id, 'info', "item(i) = %s, look-ahead(j) = %s", i, j)
if i + j < len(ti_rich_list):
if ti_rich_list[i + j][1]:
write_log(
release_id, 'info', "Set to true..")
ti_rich_list[i] = (ti_bit, True)
write_log(
release_id, 'info', "...set OK")
else:
if j <= p - d:
ti_rich_list[i] = (ti_bit, True)
else:
p = self.options[track]["cwp_proximity"]
start = False
if not ti_rich_list[i][1]:
p -= 1
ep -= 1
write_log(
release_id,
'info',
"ti_rich_list after gapping (True indicates new words plus infills) = %s",
ti_rich_list)
nothing_new = True
for (ti_bit, new) in ti_rich_list:
if new:
nothing_new = False
new_prev = True
break
if nothing_new:
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
None)
return None
else:
new_prev = False
for i, (ti_bit, new) in enumerate(ti_rich_list):
write_log(release_id, 'info', "Create new for %s?", ti_bit)
if new:
write_log(release_id, 'info', "Yes for %s", ti_bit)
if not new_prev:
if i > 0:
# check to see if the last char of the prev
# punctuation group needs to be added first
if len(ti_rich_list[i - 1][0][1]) > 1:
# i.e. ti_bit[1][-1] of previous loop
ti_new.append(ti_rich_list[i - 1][0][1][-1])
ti_new.append(ti_bit[0])
if len(ti_bit[1]) > 1:
if i < len(ti_rich_list) - 1:
if ti_rich_list[i + 1][1]:
ti_new.append(ti_bit[1])
else:
ti_new.append(ti_bit[1][:-1])
else:
ti_new.append(ti_bit[1])
else:
ti_new.append(ti_bit[1])
write_log(
release_id,
'info',
"appended %s. ti_new is now %s",
ti_bit,
ti_new)
else:
write_log(release_id, 'info', "Not for %s", ti_bit)
if new != new_prev:
ti_new.append(u"\u2026" + ' ')
new_prev = new
if ti_new:
write_log(release_id, 'info', "ti_new %s", ti_new)
ti = ''.join(ti_new)
write_log(release_id, 'info', "New text from title = %s", ti)
else:
write_log(release_id, 'info', "New text empty")
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
None)
return None
# see if there is any significant difference between the strings
if ti:
nopunc_ti = ti_compare # was = self.boil(release_id, ti)
# not necessary as already set?
nopunc_mb = self.boil(release_id, mb)
# ti_len = len(nopunc_ti) use compare_length instead (= len before
# removals and additions)
substring_proportion = float(
self.options[track]["cwp_substring_match"]) / 100
sub_len = compare_length * substring_proportion
if substring_proportion < 1:
write_log(release_id, 'info', "test sub....")
lcs = longest_common_substring(nopunc_mb, nopunc_ti)['string']
write_log(
release_id,
'info',
"Longest common substring is: %s. Threshold length is %s",
lcs,
sub_len)
if len(lcs) >= sub_len:
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
None)
return None
write_log(release_id, 'info', "...done, ti =%s", ti)
# remove duplicate successive words (and remove first word of title
# item if it duplicates last word of mb item)
if ti:
ti_list_new = re.split(' ', ti)
ti_list_ref = ti_list_new
ti_bit_prev = None
for i, ti_bit in enumerate(ti_list_ref):
if ti_bit != "...":
if i > 1:
if self.boil(
release_id, ti_bit) == self.boil(
release_id, ti_bit_prev):
dup = ti_list_new.pop(i)
write_log(release_id, 'info', "...removed dup %s", dup)
ti_bit_prev = ti_bit
if ti_list_new and mb_list2:
write_log(release_id,
'info',
"1st word of ti = %s. Last word of mb = %s",
ti_list_new[0],
mb_list2[-1])
if self.boil(release_id, ti_list_new[0]) == mb_list2[-1]:
write_log(release_id, 'info', "Removing 1st word from ti...")
first = ti_list_new.pop(0)
write_log(release_id, 'info', "...removed %s", first)
else:
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
None)
return None
if ti_list_new:
ti = ' '.join(ti_list_new)
else:
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
None)
return None
# remove excess brackets and punctuation
if ti:
ti = strip_excess_punctuation(ti)
write_log(release_id, 'info', "stripped punc ok. ti = %s", ti)
write_log(
release_id,
'debug',
"DIFF_PAIR is returning ti = %s",
ti)
if ti and len(ti) > 0:
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
ti)
return ti
else:
write_log(
release_id,
'info',
'End of DIFF_PAIR. Returning %s',
None)
return None
@staticmethod
def canonize_opus(release_id, track, s):
"""
make opus numbers etc. into one-word items
:param release_id:
:param track:
:param s: A string
:return:
"""
write_log(release_id, 'debug', 'Canonizing: %s', s)
# Canonize catalogue & opus numbers (e.g. turn K. 126 into K126 or K
# 345a into K345a or op. 144 into op144):
regex = re.compile(
r'\b((?:op|no|k|kk|kv|L|B|Hob|S|D|M)|\w+WV)\W?\s?(\d+\-?\u2013?\u2014?\d*\w*)\b',
re.IGNORECASE)
regex_match = regex.search(s)
s_canon = s
if regex_match and len(regex_match.groups()) == 2:
pt1 = regex_match.group(1) or ''
pt2 = regex_match.group(2) or ''
if regex_match.group(1) and regex_match.group(2):
pt1 = re.sub(
r'^\W*no\b',
'',
regex_match.group(1),
flags=re.IGNORECASE)
s_canon = pt1 + pt2
write_log(release_id, 'info', 'canonized item = %s', s_canon)
return s_canon
@staticmethod
def canonize_key(release_id, track, s):
"""
make keys into standardized one-word items
:param release_id:
:param track:
:param s: A string
:return:
"""
write_log(release_id, 'debug', 'Canonizing: %s', s)
match = RE_KEYS.search(s)
s_canon = s
if match:
if match.group(2):
k2 = re.sub(
r'\-sharp|\u266F',
'sharp',
match.group(2),
flags=re.IGNORECASE)
k2 = re.sub(r'\-flat|\u266D', 'flat', k2, flags=re.IGNORECASE)
k2 = k2.replace('-', '')
else:
k2 = ''
if not match.group(3) or match.group(
3).strip() == '': # if the scale is not given, assume it is the major key
if match.group(1).isupper(
) or k2 != '': # but only if it is upper case or has an accent
k3 = 'major'
else:
k3 = ''
else:
k3 = match.group(3).strip()
s_canon = match.group(1).strip() + k2.strip() + k3
write_log(release_id, 'info', 'canonized item = %s', s_canon)
return s_canon
@staticmethod
def canonize_synonyms(release_id, tuples, s):
"""
make synonyms equal
:param release_id:
:param tuples
:param s: A string
:return:
"""
write_log(release_id, 'debug', 'Canonizing: %s', s)
s_canon = s
syn_patterns = []
syn_subs = []
for syn_tup in tuples:
syn_pattern = r'((?:^|\W)' + \
r'(?:$|\W)|(?:^|\W)'.join(syn_tup) + r'(?:$|\W))'
syn_patterns.append(syn_pattern)
# to get the last synonym in the tuple - the canonical form
syn_sub = syn_tup[-1:][0]
syn_subs.append(syn_sub)
for syn_ind, pattern in enumerate(syn_patterns):
regex = re.compile(pattern, re.IGNORECASE)
regex_match = regex.search(s)
if regex_match:
test_reg = regex_match.group().strip()
s_canon = s_canon.replace(test_reg, syn_subs[syn_ind])
write_log(release_id, 'info', 'canonized item = %s', s_canon)
return s_canon
def find_synonyms(self, release_id, track, reg_item):
"""
extend regex item to include synonyms
:param release_id:
:param track:
:param reg_item: A regex portion
:return: reg_new: A replacement for reg_item that includes all its synonyms
(if reg_item matches the last in a synonym tuple)
"""
write_log(release_id, 'debug', 'Finding synonyms of: %s', reg_item)
syn_others = []
syn_all = []
for syn_tup in self.synonyms[track]:
# to get the last synonym in the tuple - the canonical form
syn_last = syn_tup[-1:][0]
if re.match(r'^\s*' + reg_item + r'\s*$', syn_last, re.IGNORECASE):
syn_others += syn_tup[:-1]
syn_all += syn_tup
if syn_others:
reg_item = '(?:' + ')|(?:'.join(syn_others) + \
')|(?:' + reg_item + ')'
write_log(release_id, 'info', 'new regex item = %s', reg_item)
return reg_item, syn_all
def listify(self, release_id, track, s):
"""
Turn a string into a list of 'words', where words may also be phrases which
are then 'canonized' - i.e. turned into equivalents for comparison purposes
:param release_id:
:param track:
:param s: string
:return: s_tuple: a tuple of all the **match objects** (re words and defined phrases)
s_test_tuple: a tuple of the matched and canonized words and phrases (i.e. a tuple of strings, not objects)
"""
tuples = self.synonyms[track]
# just list anything that is a synonym (with word boundary markers)
syn_pattern = '|'.join(
[r'(?:^|\W|\b)' + x + r'(?:$|\W)' for y in self.synonyms[track] for x in y])
op = self.find_synonyms(
release_id,
track,
r'(?:op|no|k|kk|kv|L|B|Hob|S|D|M|\w+WV)')
op_groups = op[0]
op_all = op[1]
notes = self.find_synonyms(release_id, track, r'[ABCDEFG]')
notes_groups = notes[0]
notes_all = notes[1]
sharp = self.find_synonyms(release_id, track, r'sharp')
sharp_groups = sharp[0]
sharp_all = sharp[1]
flat = self.find_synonyms(release_id, track, r'flat')
flat_groups = flat[0]
flat_all = flat[1]
major = self.find_synonyms(release_id, track, r'major')
major_groups = major[0]
major_all = major[1]
minor = self.find_synonyms(release_id, track, r'minor')
minor_groups = minor[0]
minor_all = minor[1]
opus_pattern = r"(?:\b((?:(" + op_groups + \
r"))\W?\s?\d+\-?\u2013?\u2014?\d*\w*)\b)"
note_pattern = r"(\b" + notes_groups + r")"
accent_pattern = r"(?:\-(" + sharp_groups + r")(?:\s+|\b)|\-(" + flat_groups + r")(?:\s+|\b)|\s(" + sharp_groups + \
r")(?:\s+|\b)|\s(" + flat_groups + r")(?:\s+|\b)|\u266F(?:\s+|\b)|\u266D(?:\s+|\b)|(?:[:,.]?\s+|$|\-))"
scale_pattern = r"(?:((" + major_groups + \
r")|(" + minor_groups + r"))?\b)"
key_pattern = note_pattern + accent_pattern + scale_pattern
hyphen_split_pattern = r"(?:\b|\"|\')(\w+['’]?\w*)|(?:\b\w+\b)|(\B\&\B)"
# treat em-dash and en-dash as hyphens
hyphen_embed_pattern = r"(?:\b|\"|\')(\w+['’\-\u2013\u2014]?\w*)|(?:\b\w+\b)|(\B\&\B)"
# The regex is split into two iterations as putting it all together can have unpredictable consequences
# - may match synonyms before op's even though that is later in the string
# First match the op's and keys
regex_1 = opus_pattern + r"|(" + key_pattern + r")"
matches_1 = re.finditer(regex_1, s, re.UNICODE | re.IGNORECASE)
s_list = []
s_test_list = []
s_scrubbed = s
all_synonyms_lists = [
op_all,
notes_all,
sharp_all,
flat_all,
sharp_all,
flat_all,
major_all,
minor_all]
matches_list = [2, 4, 5, 6, 7, 8, 10, 11]
for match in matches_1:
test_a = match.group()
match_a = []
match_a.append(match.group())
for j in range(1, 12):
match_a.append(match.group(j))
# 0. overall match
# 1. overall opus match
# 2. 2-char op match
# 3. overall key match
# 4. note match
# 5. hyphenated sharp match
# 6. hyphenated flat match
# 7. non-hyphenated sharp match
# 8. non-hyphenated flat match
# 9. overall scale match
# 10. major match
# 11. minor match
for i, all_synonyms_list in enumerate(all_synonyms_lists):
if all_synonyms_list and match_a[matches_list[i]]:
match_regex = [re.match(pattern, match_a[matches_list[i]], re.IGNORECASE).group()
for pattern in all_synonyms_list
if re.match(pattern, match_a[matches_list[i]], re.IGNORECASE)]
if match_regex:
match_a[matches_list[i]] = self.canonize_synonyms(
release_id, tuples, match_a[matches_list[i]])
test_a = re.sub(r"\b" + match_regex[0] + r"(?:\b|$|\s|\.)",
match_a[matches_list[i]],
test_a, flags=re.IGNORECASE)
if match_a[1]:
clean_opus = test_a.strip(' ,.:;/-?"')
test_a = re.sub(
re.escape(clean_opus),
self.canonize_opus(
release_id,
track,
clean_opus),
test_a,
flags=re.IGNORECASE)
if match_a[3]:
clean_key = test_a.strip(' ,.:;/-?"')
test_a = re.sub(
re.escape(clean_key),
self.canonize_key(
release_id,
track,
clean_key),
test_a,
flags=re.IGNORECASE)
s_test_list.append(test_a)
s_list.append(match)
s_scrubbed_list = list(s_scrubbed)
for char in range(match.start(), match.end()):
if len(s_scrubbed_list) >= match.end(): # belt and braces
s_scrubbed_list[char] = '#'
s_scrubbed = ''.join(s_scrubbed_list)
# Then match the synonyms and remaining words
if self.options[track]["cwp_split_hyphenated"]:
regex_2 = r"(" + syn_pattern + r")|" + hyphen_split_pattern
# allow ampersands and non-latin characters as word characters. Treat apostrophes as part of words.
# Treat opus and catalogue entries - e.g. K. 657 or OP.5 or op. 35a or CD 144 or BWV 243a - as one word
# also treat ranges of opus numbers (connected by dash, en dash or
# em dash) as one word
else:
regex_2 = r"(" + syn_pattern + r")|" + hyphen_embed_pattern
# as previous but also treat embedded hyphens as part of words.
matches_2 = re.finditer(
regex_2, s_scrubbed, re.UNICODE | re.IGNORECASE)
for match in matches_2:
if match.group(1) and match.group(1) == match.group():
s_test_list.append(
self.canonize_synonyms(
release_id,
tuples,
match.group(1))) # synonym
else:
s_test_list.append(match.group())
s_list.append(match)
if s_list:
s_zip = list(zip(s_list, s_test_list))
s_list, s_test_list = zip(
*sorted(s_zip, key=lambda tup: tup[0].start()))
s_tuple = tuple(s_list)
s_test_tuple = tuple(s_test_list)
return {'s_tuple': s_tuple, 's_test_tuple': s_test_tuple}
def get_text_tuples(self, release_id, track, text_type):
"""
Return synonym or 'replacement' tuples
:param release_id:
:param track:
:param text_type: 'replacements' or 'synonyms'
Note that code in this method refers to synonyms (as that was written first), but applies equally to replacements and ui_tags
:return:
"""
tm = track.metadata
strsyns = re.split(r'(?= 2:
for i, ts in enumerate(tup):
tup[i] = ts.strip("' ").strip('"')
if len(
tup[i]) > 4 and tup[i][0] == "!" and tup[i][1] == "!" and tup[i][-1] == "!" and tup[i][-2] == "!":
# we have a reg ex inside - this deals with legacy
# replacement text where enclosure in double-shouts was
# required
tup[i] = tup[i][2:-2]
if (i < len(tup) - 1 or text_type ==
'synonyms') and not tup[i]:
write_log(
release_id,
'warning',
'%s: entries must not be blank - error in %s',
text_type,
syn)
if self.WARNING:
self.append_tag(
release_id,
tm,
'~cwp_warning',
'7. ' + text_type + ': entries must not be blank - error in ' + syn)
tup[i] = "**BAD**"
elif [tup for t in synonyms if tup[i] in t]:
write_log(
release_id,
'warning',
'%s: keys cannot duplicate any in existing %s - error in %s '
'- omitted from %s. To fix, place all %s in one tuple.',
text_type,
text_type,
syn,
text_type,
text_type)
if self.WARNING:
self.append_tag(release_id, tm, '~cwp_warning',
'7. ' + text_type + ': keys cannot duplicate any in existing ' + text_type + ' - error in ' +
syn + ' - omitted from ' + text_type + '. To fix, place all ' + text_type + ' in one tuple.')
tup[i] = "**BAD**"
if "**BAD**" in tup:
continue
else:
synonyms.append(tup)
else:
write_log(
release_id,
'warning',
'Error in %s format for %s',
text_type,
syn)
if self.WARNING:
self.append_tag(
release_id,
tm,
'~cwp_warning',
'7. Error in ' +
text_type +
' format for ' +
syn)
write_log(release_id, 'info', "%s: %s", text_type, synonyms)
return synonyms
@staticmethod
def stencil(release_id, matches_tuple, test_string):
"""
Produce lists of matching items, AND the items in between, in equal length lists
:param release_id:
:param matches_tuple: tuple of regex matches
:param test_string: original string used in regex
:return: 'match list' - list of matched strings, 'gap list' - list of strings in gaps between matches
"""
match_items = []
gap_items = []
dummy = False
pointer = 0
write_log(
release_id,
'debug',
'In fn stencil. test_string = %s. matches_tuple = %s',
test_string,
matches_tuple)
for match_num, match in enumerate(matches_tuple):
start = match.start()
end = match.end()
if start > pointer:
if pointer == 0:
# add a null word item at start to keep the lists the same
# length
match_items.append('')
dummy = True
gap_items.append(test_string[pointer:start])
else:
if pointer > 0:
# shouldn't happen, but just in case there are two word
# items with no gap
gap_items.append('')
match_items.append(test_string[start:end])
pointer = end
if match_num + 1 == len(matches_tuple):
# pick up any punc items at end
gap_items.append(test_string[pointer:])
return {
'match list': match_items,
'gap list': gap_items,
'dummy': dummy}
def boil(self, release_id, s):
"""
Remove punctuation, spaces, capitals and accents for string comparisons
:param release_id: name for log file - usually =musicbrainz_albumid
unless called outside metadata processor
:param s:
:return:
"""
write_log(release_id, 'debug', "boiling %s", s)
s = s.lower()
s = replace_roman_numerals(s)
s = s.replace('sch', 'sh')\
.replace(u'\xdf', 'ss')\
.replace('sz', 'ss')\
.replace(u'\u0153', 'oe')\
.replace('oe', 'o')\
.replace(u'\u00fc', 'ue')\
.replace('ue', 'u')\
.replace(u'\u00e6', 'ae')\
.replace('ae', 'a')\
.replace(u'\u266F', 'sharp')\
.replace(u'\u266D', 'flat')\
.replace(u'\u2013', '-')\
.replace(u'\u2014', '-')
# first term above is to remove the markers used for synonyms, to
# enable a true comparison
punc = re.compile(r'\W*', re.ASCII)
s = ''.join(
c for c in unicodedata.normalize(
'NFD',
s) if unicodedata.category(c) != 'Mn')
boiled = punc.sub('', s).strip().lower().rstrip("s'")
write_log(release_id, 'debug', "boiled result = %s", boiled)
return boiled
################
# OPTIONS PAGE #
################
class ClassicalExtrasOptionsPage(OptionsPage):
NAME = "classical_extras"
TITLE = "Classical Extras"
PARENT = "plugins"
HELP_URL = "http://music.highmossergate.co.uk/symphony/tagging/classical-extras/"
opts = plugin_options('artists') + plugin_options('tag') + plugin_options('tag_detail') +\
plugin_options('workparts') + plugin_options('genres') + plugin_options('other')
options = [
IntOption("persist", 'ce_tab', 0)
]
# custom logging for non-album-related messages is written to session.log
for opt in opts:
if 'type' in opt:
if 'default' in opt:
default = opt['default']
else:
default = ""
if opt['type'] == 'Boolean':
options.append(BoolOption("setting", opt['option'], default))
elif opt['type'] == 'Text' or opt['type'] == 'Combo' or opt['type'] == 'PlainText':
options.append(TextOption("setting", opt['option'], default))
elif opt['type'] == 'Integer':
options.append(IntOption("setting", opt['option'], default))
else:
write_log(
"session",
'error',
"Error in setting options for option = %s",
opt['option'])
def __init__(self, parent=None):
super(ClassicalExtrasOptionsPage, self).__init__(parent)
self.ui = Ui_ClassicalExtrasOptionsPage()
self.ui.setupUi(self)
def load(self):
"""
Load the options - NB all options are set in plugin_options, so this just parses that
:return:
"""
opts = plugin_options('artists') + plugin_options('tag') + plugin_options('tag_detail') +\
plugin_options('workparts') + plugin_options('genres') + plugin_options('other')
# To force a toggle so that signal given
toggle_list = ['use_cwp',
'use_cea',
'cea_override',
'cwp_override',
'cea_ra_use',
'cea_split_lyrics',
'cwp_partial',
'cwp_arrangements',
'cwp_medley',
'cwp_use_muso_refdb',
'ce_show_ui_tags',]
# open at last used tab
if 'ce_tab' in config.persist:
cfg_val = config.persist['ce_tab'] or 0
if 0 <= cfg_val <= 5:
self.ui.tabWidget.setCurrentIndex(cfg_val)
else:
self.ui.tabWidget.setCurrentIndex(0)
for opt in opts:
if opt['option'] == 'classical_work_parts':
ui_name = 'use_cwp'
elif opt['option'] == 'classical_extra_artists':
ui_name = 'use_cea'
else:
ui_name = opt['option']
if ui_name in toggle_list:
not_setting = not self.config.setting[opt['option']]
self.ui.__dict__[ui_name].setChecked(not_setting)
if opt['type'] == 'Boolean':
self.ui.__dict__[ui_name].setChecked(
self.config.setting[opt['option']])
elif opt['type'] == 'Text':
self.ui.__dict__[ui_name].setText(
self.config.setting[opt['option']])
elif opt['type'] == 'PlainText':
self.ui.__dict__[ui_name].setPlainText(
self.config.setting[opt['option']])
elif opt['type'] == 'Combo':
self.ui.__dict__[ui_name].setEditText(
self.config.setting[opt['option']])
elif opt['type'] == 'Integer':
self.ui.__dict__[ui_name].setValue(
self.config.setting[opt['option']])
else:
write_log(
'session',
'error',
"Error in loading options for option = %s",
opt['option'])
def save(self):
opts = plugin_options('artists') + plugin_options('tag') + plugin_options('tag_detail') +\
plugin_options('workparts') + plugin_options('genres') + plugin_options('other')
# save tab setting
config.persist['ce_tab'] = self.ui.tabWidget.currentIndex()
for opt in opts:
if opt['option'] == 'classical_work_parts':
ui_name = 'use_cwp'
elif opt['option'] == 'classical_extra_artists':
ui_name = 'use_cea'
else:
ui_name = opt['option']
if opt['type'] == 'Boolean':
self.config.setting[opt['option']] = self.ui.__dict__[
ui_name].isChecked()
elif opt['type'] == 'Text':
self.config.setting[opt['option']] = str(
self.ui.__dict__[ui_name].text())
elif opt['type'] == 'PlainText':
self.config.setting[opt['option']] = str(
self.ui.__dict__[ui_name].toPlainText())
elif opt['type'] == 'Combo':
self.config.setting[opt['option']] = str(
self.ui.__dict__[ui_name].currentText())
elif opt['type'] == 'Integer':
self.config.setting[opt['option']
] = self.ui.__dict__[ui_name].value()
else:
write_log(
'session',
'error',
"Error in saving options for option = %s",
opt['option'])
#################
# MAIN ROUTINE #
#################
# custom logging for non-album-related messages is written to session.log
write_log('session', 'basic', 'Loading ' + PLUGIN_NAME)
# SET UI COLUMNS FOR PICARD RHS
if config.setting['ce_show_ui_tags'] and config.setting['ce_ui_tags']:
from picard.ui.itemviews import MainPanel
UI_TAGS = get_ui_tags().items()
for heading, tag_names in UI_TAGS:
heading_tag = '~' + heading + '_VAL'
MainPanel.columns.append((N_(heading), heading_tag))
write_log('session', 'info', 'UI_TAGS')
write_log('session', 'info', UI_TAGS)
# set defaults for certain options that MUST be manually changed by the
# user each time they are to be over-ridden
config.setting['use_cache'] = True
config.setting['ce_options_overwrite'] = False
config.setting['track_ars'] = True
config.setting['release_ars'] = True
# REFERENCE DATA
REF_DICT = get_references_from_file(
'session',
config.setting['cwp_muso_path'],
config.setting['cwp_muso_refdb'])
write_log('session', 'info', 'External references (Muso):')
write_log('session', 'info', REF_DICT)
COMPOSER_DICT = REF_DICT['composers']
if config.setting['cwp_muso_classical'] and not COMPOSER_DICT:
write_log('session', 'error', 'No composer roster found')
for cd in COMPOSER_DICT:
cd['lc_name'] = [c.lower() for c in cd['name']]
cd['lc_sort'] = [c.lower() for c in cd['sort']]
PERIOD_DICT = REF_DICT['periods']
if (config.setting['cwp_muso_dates']
or config.setting['cwp_muso_periods']) and not PERIOD_DICT:
write_log('session', 'error', 'No period map found')
GENRE_DICT = REF_DICT['genres']
if config.setting['cwp_muso_genres'] and not GENRE_DICT:
write_log('session', 'error', 'No classical genre list found')
# API CALLS
register_track_metadata_processor(PartLevels().add_work_info)
register_track_metadata_processor(ExtraArtists().add_artist_info)
register_options_page(ClassicalExtrasOptionsPage)
# END
write_log('session', 'basic', 'Finished intialisation')
================================================
FILE: plugins/classical_extras/const.py
================================================
# -*- coding: utf-8 -*-
"""
Declare constants for Picard Classical Extras plugin
v2.0.2
"""
# Copyright (C) 2018 Mark Evens
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
RELATION_TYPES = {
'work': [
'arranger',
'instrument arranger',
'orchestrator',
'composer',
'writer',
'lyricist',
'librettist',
'revised by',
'translator',
'reconstructed by',
'vocal arranger'],
'release': [
'instrument',
'performer',
'vocal',
'performing orchestra',
'conductor',
'chorus master',
'concertmaster',
'arranger',
'instrument arranger',
'orchestrator',
'vocal arranger'],
'recording': [
'instrument',
'performer',
'vocal',
'performing orchestra',
'conductor',
'chorus master',
'concertmaster',
'arranger',
'instrument arranger',
'orchestrator',
'vocal arranger']}
ARTISTS_OPTIONS = [
{'option': 'classical_extra_artists',
'name': 'run extra artists',
'type': 'Boolean',
'default': True
},
{'option': 'cea_orchestras',
'name': 'orchestra strings',
'type': 'Text',
'default': 'orchestra, philharmonic, philharmonica, philharmoniker, musicians, academy, symphony, orkester'
},
{'option': 'cea_choirs',
'name': 'choir strings',
'type': 'Text',
'default': 'choir, choir vocals, chorus, singers, domchors, domspatzen, koor, kammerkoor'
},
{'option': 'cea_groups',
'name': 'group strings',
'type': 'Text',
'default': 'ensemble, band, group, trio, quartet, quintet, sextet, septet, octet, chamber, consort, players, '
'les ,the , quartett'
},
{'option': 'cea_aliases',
'name': 'replace artist name with alias?',
'value': 'replace',
'type': 'Boolean',
'default': True
},
{'option': 'cea_aliases_composer',
'name': 'replace artist name with alias?',
'value': 'composer',
'type': 'Boolean',
'default': False
},
{'option': 'cea_no_aliases',
'name': 'replace artist name with alias?',
'value': 'no replace',
'type': 'Boolean',
'default': False
},
{'option': 'cea_alias_overrides',
'name': 'alias vs credited-as',
'value': 'alias over-rides',
'type': 'Boolean',
'default': True
},
{'option': 'cea_credited_overrides',
'name': 'alias vs credited-as',
'value': 'credited-as over-rides',
'type': 'Boolean',
'default': False
},
{'option': 'cea_ra_use',
'name': 'use recording artist',
'type': 'Boolean',
'default': False
},
{'option': 'cea_ra_trackartist',
'name': 'recording artist name style',
'value': 'track artist',
'type': 'Boolean',
'default': False
},
{'option': 'cea_ra_performer',
'name': 'recording artist name style',
'value': 'performer',
'type': 'Boolean',
'default': True
},
{'option': 'cea_ra_replace_ta',
'name': 'recording artist effect on track artist',
'value': 'replace',
'type': 'Boolean',
'default': False
},
{'option': 'cea_ra_noblank_ta',
'name': 'disallow blank recording artist',
'type': 'Boolean',
'default': False
},
{'option': 'cea_ra_merge_ta',
'name': 'recording artist effect on track artist',
'value': 'merge',
'type': 'Boolean',
'default': True
},
{'option': 'cea_composer_album',
'name': 'Album prefix',
# 'value': 'Composer', # Can't use 'value' if there is only one option, otherwise False will revert to default
'type': 'Boolean',
'default': True
},
{'option': 'cea_arrangers',
'name': 'include arrangers',
'type': 'Boolean',
'default': True
},
{'option': 'cea_no_lyricists',
'name': 'exclude lyricists if no vocals',
'type': 'Boolean',
'default': True
},
{'option': 'cea_cyrillic',
'name': 'fix cyrillic',
'type': 'Boolean',
'default': True
},
# {'option': 'cea_genres',
# 'name': 'infer work types',
# 'type': 'Boolean',
# 'default': True
# },
# Note that the above is no longer used - replaced by cwp_genres_infer from v0.9.2
{'option': 'cea_credited',
'name': 'use release credited-as name',
'type': 'Boolean',
'default': True
},
{'option': 'cea_release_relationship_credited',
'name': 'use release relationship credited-as name',
'type': 'Boolean',
'default': True
},
{'option': 'cea_group_credited',
'name': 'use release-group credited-as name',
'type': 'Boolean',
'default': True
},
{'option': 'cea_recording_credited',
'name': 'use recording credited-as name',
'type': 'Boolean',
'default': False
},
{'option': 'cea_recording_relationship_credited',
'name': 'use recording relationship credited-as name',
'type': 'Boolean',
'default': True
},
{'option': 'cea_track_credited',
'name': 'use track credited-as name',
'type': 'Boolean',
'default': True
},
{'option': 'cea_performer_credited',
'name': 'use credited-as name for performer',
'type': 'Boolean',
'default': True
},
{'option': 'cea_composer_credited',
'name': 'use credited-as name for composer',
'type': 'Boolean',
'default': False
},
{'option': 'cea_inst_credit',
'name': 'use credited instrument',
'type': 'Boolean',
'default': True
},
{'option': 'cea_no_solo',
'name': 'exclude solo',
'type': 'Boolean',
'default': True
},
{'option': 'cea_chorusmaster',
'name': 'chorusmaster',
'type': 'Text',
'default': 'choirmaster'
},
{'option': 'cea_orchestrator',
'name': 'orchestrator',
'type': 'Text',
'default': 'orch.'
},
{'option': 'cea_concertmaster',
'name': 'concertmaster',
'type': 'Text',
'default': 'leader'
},
{'option': 'cea_lyricist',
'name': 'lyricist',
'type': 'Text',
'default': 'lyrics'
},
{'option': 'cea_librettist',
'name': 'librettist',
'type': 'Text',
'default': 'libretto'
},
{'option': 'cea_writer',
'name': 'writer',
'type': 'Text',
'default': 'writer'
},
{'option': 'cea_arranger',
'name': 'arranger',
'type': 'Text',
'default': 'arr.'
},
{'option': 'cea_reconstructed',
'name': 'reconstructed by',
'type': 'Text',
'default': 'reconstructed'
},
{'option': 'cea_revised',
'name': 'revised by',
'type': 'Text',
'default': 'revised'
},
{'option': 'cea_translator',
'name': 'translator',
'type': 'Text',
'default': 'trans.'
},
{'option': 'cea_split_lyrics',
'name': 'split lyrics',
'type': 'Boolean',
'default': True
},
{'option': 'cea_lyrics_tag',
'name': 'lyrics',
'type': 'Text',
'default': 'lyrics'
},
{'option': 'cea_album_lyrics',
'name': 'album lyrics',
'type': 'Text',
'default': 'albumnotes'
},
{'option': 'cea_track_lyrics',
'name': 'track lyrics',
'type': 'Text',
'default': 'tracknotes'
}
]
TAG_OPTIONS = [
{'option': 'cea_blank_tag',
'name': 'Tags to blank',
'type': 'Text',
'default': 'artist, artistsort'
},
{'option': 'cea_blank_tag_2',
'name': 'Tags to blank 2',
'type': 'Text',
'default': 'performer:orchestra, performer:choir, performer:choir vocals'
},
{'option': 'cea_keep',
'name': 'File tags to keep',
'type': 'Text',
'default': ''
},
{'option': 'cea_clear_tags',
'name': 'Clear previous tags',
'type': 'Boolean',
'default': False
},
{'option': 'cea_tag_sort',
'name': 'populate sort tags',
'type': 'Boolean',
'default': True
}
]
# (tag mapping detail lines)
default_list = [
('album_soloists, album_ensembles, album_conductors', 'artist, artists', False),
('recording_artists', 'artist, artists', True),
('soloist_names, ensemble_names, conductors', 'artist, artists', True),
('soloists', 'soloists, trackartist, involved people', False),
('release', 'release_name', False),
('ensemble_names', 'band', False),
('composers', 'artist', True),
('MB_artists', 'composer', True),
('arranger', 'composer', True)
]
TAG_DETAIL_OPTIONS = []
for i in range(0, 16):
if i < len(default_list):
default_source, default_tag, default_cond = default_list[i]
else:
default_source = ''
default_tag = ''
default_cond = False
TAG_DETAIL_OPTIONS.append({'option': 'cea_source_' + str(i + 1),
'name': 'line ' + str(i + 1) + '_source',
'type': 'Combo',
'default': default_source
})
TAG_DETAIL_OPTIONS.append({'option': 'cea_tag_' + str(i + 1),
'name': 'line ' + str(i + 1) + '_tag',
'type': 'Text',
'default': default_tag
})
TAG_DETAIL_OPTIONS.append({'option': 'cea_cond_' + str(i + 1),
'name': 'line ' + str(i + 1) + '_conditional',
'type': 'Boolean',
'default': default_cond
})
WORKPARTS_OPTIONS = [
{'option': 'classical_work_parts',
'name': 'run work parts',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_collections',
'name': 'include collection relations',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_allow_empty_parts',
# allow parts to be blank if there is arrangement or partial text label
# checked = split
'name': 'allow-empty-parts',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_common_chars',
# for use in strip_parent_from_work
# where no match exists and substring elimination is used
# this sets the minimum number of matching 'words' (words followed by punctuation/spaces or EOL)
# required before they will be eliminated
# 0 => no elimination
# default is 2 words
'name': 'min common words to eliminate',
'type': 'Integer',
'default': 2
},
{'option': 'cwp_proximity',
# proximity of new words in title comparison which will result in
# infill words being included as well. 2 means 2-word 'gaps' of
# existing words between new words will be treated as 'new'
'name': 'in-string proximity trigger',
'type': 'Integer',
'default': 2
},
{'option': 'cwp_end_proximity',
# proximity measure to be used when infilling to the end of the title
'name': 'end-string proximity trigger',
'type': 'Integer',
'default': 1
},
{'option': 'cwp_split_hyphenated',
# splitting of hyphenated words for matching purposes
# checked = split
'name': 'hyphen-splitting',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_substring_match',
# Proportion of a string to be matched to a (usually larger) string for
# it to be considered essentially similar
'name': 'similarity threshold',
'type': 'Integer',
'default': 100
},
{'option': 'cwp_fill_part',
# Fill part name with title text if it would otherwise
# have no text other than arrangement or partial annotations
'name': 'disallow empty part names',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_prepositions',
'name': 'prepositions',
'type': 'Text',
'default': "a, the, in, on, at, of, after, and, de, d'un, d'une, la, le, no, from, &, e, ed, et, un,"
" une, al, ala, alla"
},
{'option': 'cwp_removewords',
'name': 'ignore prefixes',
'type': 'Text',
'default': ' part , act , scene, movement, movt, no. , no , n., n , nr., nr , book , the , a , la , le , un ,'
' une , el , il , tableau, from , KV ,Concerto in, Concerto'
},
{'option': 'cwp_synonyms',
'name': 'synonyms',
'type': 'PlainText',
'default': '(1, one) / (2, two) / (3, three) / (&, and) / (Rezitativ, Recitativo, Recitative) / '
'(Sinfonia, Sinfonie, Symphonie, Symphony) / (Arie, Aria) / '
'(Minuetto, Menuetto, Minuetta, Menuet, Minuet) / (Bourée, Bouree , Bourrée)'
},
{'option': 'cwp_replacements',
'name': 'replacements',
'type': 'Text',
'default': '(words to be replaced, replacement words) / (please blank me, ) / (etc, etc)'
},
{'option': 'cwp_titles',
'name': 'Style',
'value': 'Titles',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_works',
'name': 'Style',
'value': 'Works',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_extended',
'name': 'Style',
'value': 'Extended',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_hierarchical_works',
'name': 'Work source',
'value': 'Hierarchy',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_level0_works',
'name': 'Work source',
'value': 'Level_0',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_derive_works_from_title',
'name': 'Derive works from title',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_movt_tag_inc',
'name': 'movement tag inc num',
'type': 'Text',
'default': 'part, movement, subtitle'
},
{'option': 'cwp_movt_tag_exc',
'name': 'movement tag exc num',
'type': 'Text',
'default': ''
},
{'option': 'cwp_movt_tag_inc1',
'name': '1-level movement tag inc num',
'type': 'Text',
'default': 'movement'
},
{'option': 'cwp_movt_tag_exc1',
'name': '1-level movement tag exc num',
'type': 'Text',
'default': ''
},
{'option': 'cwp_movt_no_tag',
'name': 'movement num tag',
'type': 'Text',
'default': 'movementnumber'
},
{'option': 'cwp_movt_tot_tag',
'name': 'movement tot tag',
'type': 'Text',
'default': 'movementtotal'
},
{'option': 'cwp_work_tag_multi',
'name': 'multi-level work tag',
'type': 'Text',
'default': 'groupheading, work'
},
{'option': 'cwp_work_tag_single',
'name': 'single level work tag',
'type': 'Text',
'default': ''
},
{'option': 'cwp_top_tag',
'name': 'top level work tag',
'type': 'Text',
'default': 'top_work, style, grouping'
},
{'option': 'cwp_multi_work_sep',
'name': 'multi-level work separator',
'type': 'Combo',
'default': ':'
},
{'option': 'cwp_single_work_sep',
'name': 'single level work separator',
'type': 'Combo',
'default': ':'
},
{'option': 'cwp_movt_no_sep',
'name': 'movement number separator',
'type': 'Combo',
'default': '.'
},
{'option': 'cwp_partial',
'name': 'show partial recordings',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_partial_text',
'name': 'partial text',
'type': 'Text',
'default': '(part)'
},
{'option': 'cwp_arrangements',
'name': 'include arrangement of',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_arrangements_text',
'name': 'arrangements text',
'type': 'Text',
'default': 'Arrangement:'
},
{'option': 'cwp_medley',
'name': 'list medleys',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_medley_text',
'name': 'medley text',
'type': 'Text',
'default': 'Medley'
}
]
# Options on "Genres etc." tab
GENRE_OPTIONS = [
{'option': 'cwp_genre_tag',
'name': 'main genre tag',
'type': 'Text',
'default': 'genre'
},
{'option': 'cwp_subgenre_tag',
'name': 'sub-genre tag',
'type': 'Text',
'default': 'sub-genre'
},
{'option': 'cwp_genres_use_file',
'name': 'source genre from file',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_genres_use_folks',
'name': 'source genre from folksonomy tags',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_genres_use_worktype',
'name': 'source genre from work-type(s)',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_genres_infer',
'name': 'infer genre from artist details(s)',
'type': 'Boolean',
'default': False
},
# Note that the "infer from artists" option was in the "artists"
# section - legacy from v0.9.1 & prior
{'option': 'cwp_genres_filter',
'name': 'apply filter to genres',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_genres_classical_main',
'name': 'classical main genres',
'type': 'PlainText',
'default': 'Classical, Chamber music, Concerto, Symphony, Opera, Orchestral, Sonata, Choral, Aria, Ballet, '
'Oratorio, Motet, Symphonic poem, Suite, Partita, Song-cycle, Overture, '
'Mass, Cantata'
},
{'option': 'cwp_genres_classical_sub',
'name': 'classical sub-genres',
'type': 'PlainText',
'default': 'Chant, Classical crossover, Minimalism, Avant-garde, Impressionist, Aria, Duet, Trio, Quartet'
},
{'option': 'cwp_genres_other_main',
'name': 'general main genres',
'type': 'PlainText',
'default': 'Alternative music, Blues, Country, Dance, Easy listening, Electronic music, Folk, Folk / pop, '
'Hip hop / rap, Indie, Religious, Asian, Jazz, Latin, New age, Pop, R&B / Soul, Reggae, Rock, '
'World music, Celtic folk, French Medieval'
},
{'option': 'cwp_genres_other_sub',
'name': 'general sub-genres',
'type': 'PlainText',
'default': 'Song, Vocal, Christmas, Instrumental'
},
{'option': 'cwp_genres_arranger_as_composer',
'name': 'treat arranger as for composer for genre-setting',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_genres_classical_all',
'name': 'make tracks classical',
'value': 'all',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_genres_classical_selective',
'name': 'make tracks classical',
'value': 'selective',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_genres_classical_exclude',
'name': 'exclude "classical" from main genre tag',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_genres_flag_text',
'name': 'classical flag',
'type': 'Text',
'default': '1'
},
{'option': 'cwp_genres_flag_tag',
'name': 'classical flag tag',
'type': 'Text',
'default': 'is_classical'
},
{'option': 'cwp_genres_default',
'name': 'default genre',
'type': 'Text',
'default': 'Other'
},
{'option': 'cwp_instruments_tag',
'name': 'instruments tag',
'type': 'Text',
'default': 'instrument'
},
{'option': 'cwp_instruments_MB_names',
'name': 'use MB instrument names',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_instruments_credited_names',
'name': 'use credited instrument names',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_key_tag',
'name': 'key tag',
'type': 'Text',
'default': 'key'
},
{'option': 'cwp_key_contingent_include',
'name': 'contingent include key in workname',
'value': 'contingent',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_key_never_include',
'name': 'never include key in workname',
'value': 'never',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_key_include',
'name': 'include key in workname',
'value': 'always',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_workdate_tag',
'name': 'workdate tag',
'type': 'Text',
'default': 'work_year'
},
{'option': 'cwp_workdate_source_composed',
'name': 'use composed for workdate',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_workdate_source_published',
'name': 'use published for workdate',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_workdate_source_premiered',
'name': 'use premiered for workdate',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_workdate_use_first',
'name': 'use workdate sources sequentially',
'value': 'sequence',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_workdate_use_all',
'name': 'use all workdate sources',
'value': 'all',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_workdate_annotate',
'name': 'annotate dates',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_workdate_include',
'name': 'include workdate in workname',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_period_tag',
'name': 'period tag',
'type': 'Text',
'default': 'period'
},
{'option': 'cwp_periods_arranger_as_composer',
'name': 'treat arranger as for composer for period-setting',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_period_map',
'name': 'period map',
'type': 'PlainText',
'default': 'Early, -3000,800; Medieval, 800,1400; Renaissance, 1400, 1600; Baroque, 1600,1750; '
'Classical, 1750,1820; Early Romantic, 1800,1850; Late Romantic, 1850,1910; '
'20th Century, 1910,1975; Contemporary, 1975,2525'
}
]
# Picard options which are also saved (NB only affects plugin processing - not main Picard processing)
PICARD_OPTIONS = [
{'option': 'standardize_artists',
'name': 'standardize artists',
'type': 'Boolean',
'default': False
},
{'option': 'translate_artist_names',
'name': 'translate artist names',
'type': 'Boolean',
'default': True
},
]
# other options (not saved in file tags)
OTHER_OPTIONS = [
{'option': 'use_cache',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_aliases',
'name': 'replace with alias?',
'value': 'replace',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_no_aliases',
'name': 'replace with alias?',
'value': 'no replace',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_aliases_all',
'name': 'alias replacement type',
'value': 'all',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_aliases_greek',
'name': 'alias replacement type',
'value': 'non-latin',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_aliases_tagged',
'name': 'alias replacement type',
'value': 'tagged works',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_aliases_tag_text',
'name': 'use_alias tag text',
'type': 'Text',
'default': 'use_alias'
},
{'option': 'cwp_aliases_tags_all',
'name': 'use_alias tags all',
'type': 'Boolean',
'default': True
},
{'option': 'cwp_aliases_tags_user',
'name': 'use_alias tags user',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_use_sk',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_write_sk',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_retries',
'type': 'Integer',
'default': 6
},
{'option': 'cwp_use_muso_refdb',
'name': 'use Muso ref database',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_muso_genres',
'name': 'use Muso classical genres',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_muso_classical',
'name': 'use Muso classical composers',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_muso_dates',
'name': 'use Muso composer dates',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_muso_periods',
'name': 'use Muso periods',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_muso_path',
'name': 'path to Muso database',
'type': 'Text',
'default': 'C:\\Users\\Public\\Music\\muso\\database'
},
{'option': 'cwp_muso_refdb',
'name': 'name of Muso reference database',
'type': 'Text',
'default': 'Reference.xml'
},
{'option': 'log_error',
'type': 'Boolean',
'default': True
},
{'option': 'log_warning',
'type': 'Boolean',
'default': True
},
{'option': 'log_debug',
'type': 'Boolean',
'default': False
},
{'option': 'log_basic',
'type': 'Boolean',
'default': True
},
{'option': 'log_info',
'type': 'Boolean',
'default': False
},
{'option': 'ce_version_tag',
'type': 'Text',
'default': 'stamp'
},
{'option': 'cea_options_tag',
'type': 'Text',
'default': 'comment'
},
{'option': 'cwp_options_tag',
'type': 'Text',
'default': 'comment'
},
{'option': 'cea_override',
'type': 'Boolean',
'default': False
},
{'option': 'ce_tagmap_override',
'type': 'Boolean',
'default': False
},
{'option': 'cwp_override',
'type': 'Boolean',
'default': False
},
{'option': 'ce_genres_override',
'type': 'Boolean',
'default': False
},
{'option': 'ce_options_overwrite',
'type': 'Boolean',
'default': False
},
{'option': 'ce_no_run',
'type': 'Boolean',
'default': False
},
{'option': 'ce_show_ui_tags',
'type': 'Boolean',
'default': False
},
{'option': 'ce_ui_tags',
## Note that this is not just for work parts (although that is the main use),
# but cwp prefix is to make use of code for synonyms
'name': 'tags for ui columns',
'type': 'PlainText',
'default': 'Work diff: (groupheading_DIFF, work_DIFF, top_work_DIFF, grouping_DIFF) / Part diff: (part_DIFF, movement_DIFF) / Missing file metadata: 002_important_warning'
}
]
ARTIST_TYPE_ORDER = {'vocal': 1,
'instrument': 1,
'performer': 0,
'performing orchestra': 2,
'concertmaster': 3,
'conductor': 4,
'chorus master': 5,
'composer': 6,
'writer': 7,
'reconstructed by': 8,
'instrument arranger': 9,
'vocal arranger': 9,
'arranger': 11,
'orchestrator': 12,
'revised by': 13,
'lyricist': 14,
'librettist': 15,
'translator': 16
}
CYRILLIC_UPPER = {
u'А': u'A',
u'Б': u'B',
u'В': u'V',
u'Г': u'G',
u'Д': u'D',
u'Е': u'E',
u'Ё': u'E',
u'Ж': u'Zh',
u'З': u'Z',
u'И': u'I',
u'Й': u'Y',
u'К': u'K',
u'Л': u'L',
u'М': u'M',
u'Н': u'N',
u'О': u'O',
u'П': u'P',
u'Р': u'R',
u'С': u'S',
u'Т': u'T',
u'У': u'U',
u'Ф': u'F',
u'Х': u'H',
u'Ц': u'Ts',
u'Ч': u'Ch',
u'Ш': u'Sh',
u'Щ': u'Sch',
u'Ъ': u'',
u'Ы': u'Y',
u'Ь': u'',
u'Э': u'E',
u'Ю': u'Yu',
u'Я': u'Ya'
}
CYRILLIC_LOWER = {
u'а': u'a',
u'б': u'b',
u'в': u'v',
u'г': u'g',
u'д': u'd',
u'е': u'e',
u'ё': u'e',
u'ж': u'zh',
u'з': u'z',
u'и': u'i',
u'й': u'y',
u'к': u'k',
u'л': u'l',
u'м': u'm',
u'н': u'n',
u'о': u'o',
u'п': u'p',
u'р': u'r',
u'с': u's',
u'т': u't',
u'у': u'u',
u'ф': u'f',
u'х': u'h',
u'ц': u'ts',
u'ч': u'ch',
u'ш': u'sh',
u'щ': u'sch',
u'ъ': u'',
u'ы': u'y',
u'ь': u'',
u'э': u'e',
u'ю': u'yu',
u'я': u'ya'
}
def tag_strings(pre):
TAG_STRINGS = {
'writer': (
'composer',
pre + '_writers',
'composersort',
pre + '_writers_sort'),
'composer': (
'composer',
pre + '_composers',
'composersort',
pre + '_composers_sort'),
'lyricist': (
'lyricist',
pre + '_lyricists',
'~lyricists_sort',
pre + '_lyricists_sort'),
'librettist': (
'lyricist',
pre + '_librettists',
'~lyricists_sort',
pre + '_librettists_sort'),
'revised by': (
'arranger',
pre + '_revisors',
'~arranger_sort',
pre + '_revisors_sort'),
'translator': (
'lyricist',
pre + '_translators',
'~lyricists_sort',
pre + '_translators_sort'),
'reconstructed by': (
'arranger',
pre + '_reconstructors',
'~arranger_sort',
pre + '_reconstructors_sort'),
'arranger': (
'arranger',
pre + '_arrangers',
'~arranger_sort',
pre + '_arrangers_sort'),
'instrument arranger': (
'arranger',
pre + '_arrangers',
'~arranger_sort',
pre + '_arrangers_sort'),
'orchestrator': (
'arranger',
pre + '_orchestrators',
'~arranger_sort',
pre + '_orchestrators_sort'),
'vocal arranger': (
'arranger',
pre + '_arrangers',
'~arranger_sort',
pre + '_arrangers_sort'),
'performer': (
'performer:',
pre + '_performers',
'~performer_sort',
pre + '_performers_sort'),
'instrument': (
'performer:',
pre + '_performers',
'~performer_sort',
pre + '_performers_sort'),
'vocal': (
'performer:',
pre + '_performers',
'~performer_sort',
pre + '_performers_sort'),
'performing orchestra': (
'performer:orchestra',
pre + '_ensembles',
'~performer_sort',
pre + '_ensembles_sort'),
'conductor': (
'conductor',
pre + '_conductors',
'~conductor_sort',
pre + '_conductors_sort'),
'chorus master': (
'conductor',
pre + '_chorusmasters',
'~conductor_sort',
pre + '_chorusmasters_sort'),
'concertmaster': (
'performer',
pre + '~_leaders',
'~performer_sort',
pre + '_leaders_sort')}
return TAG_STRINGS
INSERTIONS = ['writer',
'lyricist',
'librettist',
'revised by',
'translator',
'arranger',
'reconstructed by',
'orchestrator',
'instrument arranger',
'vocal arranger',
'chorus master']
================================================
FILE: plugins/classical_extras/options_classical_extras.ui
================================================
(c) 2013
Modified by Mark Evens as part of Picard Classical Extras project
Not for stand-alone use - use the original code
Accepts list or string inputs, but returns list outputs
Changed to allow a range of different special characters in case $ is in a string
(c) 2018
"""
import sys
END_OF_STRING = sys.maxsize
class SuffixTreeNode:
"""
Suffix tree node class. Actually, it also respresents a tree edge that points to this node.
"""
new_identifier = 0
def __init__(self, start=0, end=END_OF_STRING):
self.identifier = SuffixTreeNode.new_identifier
SuffixTreeNode.new_identifier += 1
# suffix link is required by Ukkonen's algorithm
self.suffix_link = None
# child edges/nodes, each dict key represents the first letter of an edge
self.edges = {}
# stores reference to parent
self.parent = None
# bit vector shows to which strings this node belongs
self.bit_vector = 0
# edge info: start index and end index
self.start = start
self.end = end
def add_child(self, key, start, end):
"""
Create a new child node
Agrs:
key: a char that will be used during active edge searching
start, end: node's edge start and end indices
Returns:
created child node
"""
child = SuffixTreeNode(start=start, end=end)
child.parent = self
self.edges[key] = child
return child
def add_exisiting_node_as_child(self, key, node):
"""
Add an existing node as a child
Args:
key: a char that will be used during active edge searching
node: a node that will be added as a child
"""
node.parent = self
self.edges[key] = node
def get_edge_length(self, current_index):
"""
Get length of an edge that points to this node
Args:
current_index: index of current processing symbol (usefull for leaf nodes that have "infinity" end index)
"""
return min(self.end, current_index + 1) - self.start
def __str__(self):
return 'id=' + str(self.identifier)
class SuffixTree:
"""
Generalized suffix tree
"""
def __init__(self):
# the root node
self.root = SuffixTreeNode()
# all strings are concatenaited together. Tree's nodes stores only indices
self.input_string = []
# number of strings stored by this tree
self.strings_count = 0
# list of tree leaves
self.leaves = []
def append_string(self, input_string, special_char):
"""
Add new string to the suffix tree
"""
start_index = len(self.input_string)
current_string_index = self.strings_count
# each sting should have a unique ending
input_string += special_char + str(current_string_index)
# gathering 'em all together
self.input_string += input_string
self.strings_count += 1
# these 3 variables represents current "active point"
active_node = self.root
active_edge = 0
active_length = 0
# shows how many
remainder = 0
# new leaves appended to tree
new_leaves = []
# main circle
for index in range(start_index, len(self.input_string)):
previous_node = None
remainder += 1
while remainder > 0:
if active_length == 0:
active_edge = index
if self.input_string[active_edge] not in active_node.edges:
# no edge starting with current char, so creating a new leaf node
leaf_node = active_node.add_child(self.input_string[active_edge], index, END_OF_STRING)
# a leaf node will always be leaf node belonging to only one string
# (because each string has different termination)
leaf_node.bit_vector = 1 << current_string_index
new_leaves.append(leaf_node)
# doing suffix link magic
if previous_node is not None:
previous_node.suffix_link = active_node
previous_node = active_node
else:
# ok, we've got an active edge
next_node = active_node.edges[self.input_string[active_edge]]
# walking down through edges (if active_length is bigger than edge length)
next_edge_length = next_node.get_edge_length(index)
if active_length >= next_node.get_edge_length(index):
active_edge += next_edge_length
active_length -= next_edge_length
active_node = next_node
continue
# current edge already contains the suffix we need to insert.
# Increase the active_length and go forward
if self.input_string[next_node.start + active_length] == self.input_string[index]:
active_length += 1
if previous_node is not None:
previous_node.suffix_link = active_node
previous_node = active_node
break
# splitting edge
split_node = active_node.add_child(
self.input_string[active_edge],
next_node.start,
next_node.start + active_length
)
next_node.start += active_length
split_node.add_exisiting_node_as_child(self.input_string[next_node.start], next_node)
leaf_node = split_node.add_child(self.input_string[index], index, END_OF_STRING)
leaf_node.bit_vector = 1 << current_string_index
new_leaves.append(leaf_node)
# suffix link magic again
if previous_node is not None:
previous_node.suffix_link = split_node
previous_node = split_node
remainder -= 1
# follow suffix link (if exists) or go to root
if active_node == self.root and active_length > 0:
active_length -= 1
active_edge = index - remainder + 1
else:
active_node = active_node.suffix_link if active_node.suffix_link is not None else self.root
# update leaves ends from "infinity" to actual string end
for leaf in new_leaves:
leaf.end = len(self.input_string)
self.leaves.extend(new_leaves)
def find_longest_common_substrings(self, special_char):
"""
Search longest common substrings in the tree by locating lowest common ancestors that belong to all strings
"""
# all bits are set
success_bit_vector = 2 ** self.strings_count - 1
lowest_common_ancestors = []
# going up to the root
for leaf in self.leaves:
node = leaf
while node.parent is not None:
if node.bit_vector != success_bit_vector:
# updating parent's bit vector
node.parent.bit_vector |= node.bit_vector
node = node.parent
else:
# hey, we've found a lowest common ancestor!
lowest_common_ancestors.append(node)
break
longest_common_substrings = []
longest_length = 0
# need to filter the result array and get the longest common strings
for common_ancestor in lowest_common_ancestors:
common_substring = []
node = common_ancestor
while node.parent is not None:
label = self.input_string[node.start:node.end]
common_substring = label + common_substring
node = node.parent
# remove unique endings (
Select the source for \'as-credited\' names - whether these are applied depends on the sub-options choices.
")) self.names_to_use_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Names to use...")) self.cea_recording_credited.setText(_translate("ClassicalExtrasOptionsPage", "Use \"credited as\" name for work-artists/performers who are recording artists")) self.cea_group_credited.setText(_translate("ClassicalExtrasOptionsPage", "and/or release group artists")) self.cea_credited.setToolTip(_translate("ClassicalExtrasOptionsPage", "Select the tag types where any \'as-credited\' names will be applied - whether these are applied depends on the sub-options choices.
")) self.places_to_use_them_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Places to use them ...")) self.cea_performer_credited.setText(_translate("ClassicalExtrasOptionsPage", "Use for performing artists")) self.cea_composer_credited.setText(_translate("ClassicalExtrasOptionsPage", "Use for work-artists")) self.naming_sub_options_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Sub-options")) self.cea_alias_overrides.setToolTip(_translate("ClassicalExtrasOptionsPage", "Alias (if it exists) will over-ride as-credited
")) self.cea_alias_overrides.setText(_translate("ClassicalExtrasOptionsPage", "Alias over-rides credited-as")) self.cea_credited_overrides.setToolTip(_translate("ClassicalExtrasOptionsPage", "As-credited (if it exists) will over-ride alias
")) self.cea_credited_overrides.setText(_translate("ClassicalExtrasOptionsPage", "Credited-as over-rides MB/Alias")) self.cea_cyrillic.setToolTip(_translate("ClassicalExtrasOptionsPage", "Will be based on sort names. For cyrillic script names, patronyms will be removed.
")) self.cea_cyrillic.setText(_translate("ClassicalExtrasOptionsPage", "Fix non-Latin text in names (where possible and if not fixed by other naming options)")) self.MB_std_names_aliases_box_outer.setTitle(_translate("ClassicalExtrasOptionsPage", "MusicBrainz standard names and Aliases")) self.cea_no_aliases.setToolTip(_translate("ClassicalExtrasOptionsPage", "Do not use aliases (but may be replaced by as-credited name)
")) self.cea_no_aliases.setText(_translate("ClassicalExtrasOptionsPage", "Use MB standard names")) self.cea_aliases.setToolTip(_translate("ClassicalExtrasOptionsPage", "Alias will only be available for use if the work-artist/performer is also a release artist, recording artist or track artist.
")) self.cea_aliases.setText(_translate("ClassicalExtrasOptionsPage", "Use alias for all work-artists/performers")) self.cea_aliases_composer.setToolTip(_translate("ClassicalExtrasOptionsPage", "Only use alias (if available) for work-artists (writers, composers, arrangers, lyricists etc.)
")) self.cea_aliases_composer.setText(_translate("ClassicalExtrasOptionsPage", "Use alias for work-artists only")) self.recording_artist_options_label.setText(_translate("ClassicalExtrasOptionsPage", "Recording artist options
")) self.recording_artists_options_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "Select recording artist options (see "What\'s this")
")) self.recording_artists_options_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "In MusicBrainz, the recording artist may be different from the track artist. For classical music, the MusicBrainz guidelines state that the track artist should be the composer; however the recording artist(s) is/are usually the principal performer(s).
Classical Extras puts the recording artists into \'hidden variables\' (as a minimum) using the chosen naming convention.
There is also option to allow you to replace the track artist by the recording artist (or to merge them). The chosen action will be applied to the \'artist\', \'artists\', \'artistsort\' and \'artists_sort\' tags. Note that \'artist\' is a single-valued string whereas \'artists\' is a list and may be multi-valued. Lists are properly merged, but because the \'artist\' string may have different join-phrases etc, a merged tag may have the recording artist(s) in brackets after the track artist(s).
")) self.naming_convention_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "In classical music (in MusicBrainz), recording artists will usually be performers whereas track artists are composers. By default, the naming convention for performers (set in the previous section) will be used (although only the as-credited name set for the recording artist will be applied). Alternatively, the naming convention for track artists can be used - which is determined by the main Picard metadat options.
")) self.naming_convention_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Naming convention as for ...")) self.cea_ra_trackartist.setText(_translate("ClassicalExtrasOptionsPage", "...track artist (set in Picard options)")) self.cea_ra_performer.setText(_translate("ClassicalExtrasOptionsPage", "... perfomers (set above)")) self.cea_ra_use.setText(_translate("ClassicalExtrasOptionsPage", "Use recording artists to update track artists ->")) self.ra_replace_merge_options_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Replace/merge options")) self.cea_ra_replace_ta.setText(_translate("ClassicalExtrasOptionsPage", "Replace track artist by recording artist")) self.cea_ra_noblank_ta.setText(_translate("ClassicalExtrasOptionsPage", "Only replace if rec. artist exists")) self.cea_ra_merge_ta.setText(_translate("ClassicalExtrasOptionsPage", "Merge track artist and recording artist")) self.other_artist_options_label.setText(_translate("ClassicalExtrasOptionsPage", "Other artist options
")) self.annotations_lh_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter text to appear in annotations. Do not use any quotation marks.
")) self.annotations_lh_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Annotations - performers and lyricists")) self.label_44.setToolTip(_translate("ClassicalExtrasOptionsPage", "Annotation to include with "chorus master" in conductor tag.
")) self.label_44.setText(_translate("ClassicalExtrasOptionsPage", "Chorus Master")) self.label_46.setToolTip(_translate("ClassicalExtrasOptionsPage", "Annotation to include for "concert master" in performer tag.
")) self.label_46.setText(_translate("ClassicalExtrasOptionsPage", "Concert Master")) self.label_34.setToolTip(_translate("ClassicalExtrasOptionsPage", "Annotation for lyricist, to include in lyricist tag
")) self.label_34.setText(_translate("ClassicalExtrasOptionsPage", "Lyricist")) self.label_26.setToolTip(_translate("ClassicalExtrasOptionsPage", "Annotation for librettist, to include in lyricist tag
")) self.label_26.setText(_translate("ClassicalExtrasOptionsPage", "Librettist")) self.label_30.setToolTip(_translate("ClassicalExtrasOptionsPage", "Annotation for translator, to include in lyricist tag
")) self.label_30.setText(_translate("ClassicalExtrasOptionsPage", "Translator")) self.other_artist_checkboxes_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "Select as required. See "What\'s this" for details.
")) self.cea_arrangers.setToolTip(_translate("ClassicalExtrasOptionsPage", "This will gather together, for example, any arranger-type information from the recording, work or parent works and place it in the "arranger" tag (\'host\' tag), with the annotation (see details to right of this box) in brackets. All arranger types will also be put in a hidden variable, e.g. _cwp_orchestrators. The table below shows the artist types, host tag and hidden variable for each artist type.
| Artist type | Host tag | Hidden variable |
| ----------------- | ------------------| -------------------------------------- |
| writer | composer | writers |
| lyricist | lyricist | lyricists |
| librettist | lyricist | librettists |
| revised by | arranger | revisors |
| translator | lyricist | translators |
| arranger | arranger | arrangers |
| reconstructed by | arranger | reconstructors |
| orchestrator | arranger | orchestrators |
| instrument arranger | arranger | arrangers (with instrument type in brackets) |
| vocal arranger | arranger | arrangers (with voice type in brackets) |
| chorus master | conductor | chorusmasters |
| concertmaster | performer (with annotation as a sub-key) | leaders |
")) self.cea_arrangers.setText(_translate("ClassicalExtrasOptionsPage", "Modify host tags and include annotations (see =>)")) self.cea_composer_album.setToolTip(_translate("ClassicalExtrasOptionsPage", "This will add the composer(s) last name(s) before the album name, if they are listed as album artists. If there is more than one composer, they will be listed in the descending order of the length of their music on the release.
")) self.cea_composer_album.setText(_translate("ClassicalExtrasOptionsPage", "Name album as \"Composer Last Name(s): Album Name\"")) self.cea_no_lyricists.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "This applies to both the Picard \'lyricist\' tag and the related internal plugin hidden variables \'_cwp_lyricists\' etc.
")) self.cea_no_lyricists.setText(_translate("ClassicalExtrasOptionsPage", "Do not write \'lyricist\' tag if no vocal performers")) self.cea_inst_credit.setText(_translate("ClassicalExtrasOptionsPage", "Use \"credited-as\" name for instrument")) self.cea_no_solo.setToolTip(_translate("ClassicalExtrasOptionsPage", "Select to eliminate "additional", "solo" or "guest" from instrument description
")) self.cea_no_solo.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "MusicBrainz permits the use of "solo", "guest" and "additional" as instrument attributes although, for classical music, its use should be fairly rare - usually only if explicitly stated as a "solo" on the the sleevenotes. Classical Extras provides the option to exclude these attributes (the default), but you may wish to enable them for certain releases or non-Classical / cross-over releases.
")) self.cea_no_solo.setText(_translate("ClassicalExtrasOptionsPage", "Do not include attributes (e.g. \'solo\') in an instrument type")) self.annotations_rh_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter text to appear in annotations. Do not use any quotation marks.
")) self.annotations_rh_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Annotations - writers and arrangers")) self.label_56.setText(_translate("ClassicalExtrasOptionsPage", "Writer")) self.label_54.setText(_translate("ClassicalExtrasOptionsPage", "Arranger")) self.label_45.setToolTip(_translate("ClassicalExtrasOptionsPage", "Text with which to annotate orchestrator in the arranger tag.
")) self.label_45.setText(_translate("ClassicalExtrasOptionsPage", "Orchestrator")) self.label_32.setToolTip(_translate("ClassicalExtrasOptionsPage", "Annotation for "reconstructed by", to include in arranger tag
")) self.label_32.setText(_translate("ClassicalExtrasOptionsPage", "Reconstructed by")) self.label_28.setToolTip(_translate("ClassicalExtrasOptionsPage", "Annotation for "revised by", to include in arranger tag tag
")) self.label_28.setText(_translate("ClassicalExtrasOptionsPage", "Revised by")) self.lyrics_label.setText(_translate("ClassicalExtrasOptionsPage", "Lyrics
")) self.lyrics_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Please note that this section operates on the underlying input file tags, not the Picard-generated tags (MusicBrainz does not have lyrics)
Sometimes "lyrics" tags can contain album notes (repeated for every track in an album) as well as track notes and lyrics. This section will filter out the common text and place it in a different tag from the text which is unique to each track.
")) self.cea_split_lyrics.setToolTip(_translate("ClassicalExtrasOptionsPage", "enables this section
")) self.cea_split_lyrics.setText(_translate("ClassicalExtrasOptionsPage", "Split lyrics tag into track and album levels")) self.lyrics_and_notes_tags_frame.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter a valid tag name (no spaces, punctuation or special charcaters other than underline; use lower case)
")) self.label_50.setToolTip(_translate("ClassicalExtrasOptionsPage", "The name of the lyrics file tag in the input file (normally just \'lyrics\')
")) self.label_50.setText(_translate("ClassicalExtrasOptionsPage", "Incoming lyrics tag (i.e. file tag)")) self.label_51.setToolTip(_translate("ClassicalExtrasOptionsPage", "The name of the tag where common text should be placed
")) self.label_51.setText(_translate("ClassicalExtrasOptionsPage", "Tag for album notes / lyrics")) self.label_52.setToolTip(_translate("ClassicalExtrasOptionsPage", "The name of the tag where notes/lyrics unique to a track should be placed
")) self.label_52.setText(_translate("ClassicalExtrasOptionsPage", "Tag for track notes / lyrics")) self.tabWidget.setTabText(self.tabWidget.indexOf(self.Artists), _translate("ClassicalExtrasOptionsPage", "Artists")) self.use_cwp.setToolTip(_translate("ClassicalExtrasOptionsPage", ""Include all work levels" should be selected otherwise this section will not run.
")) self.use_cwp.setText(_translate("ClassicalExtrasOptionsPage", "Include all work levels (MUST BE TICKED FOR THIS SECTION TO RUN)*")) self.cwp_collections.setToolTip(_translate("ClassicalExtrasOptionsPage", "This will include parent works where the relationship has the attribute \'part of collection\'.
PLEASE BE CONSISTENT and do not use different options on albums with the same works, or the results may be unexpected.
Select to use cached works. Deselect to refesh from MusicBrainz.
")) self.use_cache.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Use cache" prevents excessive look-ups of the MB database. Every look-up of a parent work needs to be performed separately (hopefully the MB database might make this easier some day). Network usage constraints by MB means that each look-up takes a minimum of 1 second. Once a release has been looked-up, the works are retained in cache, significantly reducing the time required if, say, the options are changed and the data refreshed. However, if the user edits the works in the MB database then the cache will need to be turned off temporarily for the refresh to find the new/changed works. Also some types of work (e.g. arrangements) will require a full look-up if options have been changed.
")) self.use_cache.setText(_translate("ClassicalExtrasOptionsPage", "Use cache (if available)*")) self.work_style_label.setText(_translate("ClassicalExtrasOptionsPage", "Tagging style
")) self.work_style_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "\n" "\n" ""Tagging style". This section determines how the hierarchy of works will be sourced.
\n" "Works source: There are 3 options for determing the principal source of the works metadata
\n" ""Use only metadata from title text". The plugin will attempt to extract the hierarchy of works from the track title by looking for repetitions and patterns. If the title does not contain all the work names in the hierarchy then obviously this will limit what can be provided.
\n" ""Use only metadata from canonical works". The hierarchy in the MB database will be used. Assuming the work is correctly entered in MB, this should provide all the data. However the text may differ from the track titles and will be the same for all recordings. It may also be in the language of the composer whereas the titles will probably be in the language of the release. (This language issue can also be addressed by using aliases - see below).
\n" ""Use canonical work metadata enhanced with title text". This supplements the canonical data with text from the titles where it is significantly different. The supplementary title data will be in curly brackets. This is clearly the most complete metadata style of the three but may lead to long descriptions. It is particularly useful for providing translations.
\n" "Source of canonical work text. Where either of the second two options above are chosen, there is a further choice to be made:
\n" ""Full MusicBrainz work hierarchy". The names of each level of work are used to populate the relevant tags. E.g. if "Má vlast: I. Vyšehrad, JB 1:112/1" (level 0) is part of "Má vlast, JB 1:112" (level 1) then the parent work will be tagged as "Má vlast, JB 1:112", not "Má vlast". So, while accurate, this option might be more verbose.
\n" ""Consistent with lowest level work description". The names of the level 0 work are used to populate the relevant tags. I.e. if "Má vlast: I. Vyšehrad, JB 1:112/1" (level 0) is part of "Má vlast, JB 1:112" (level 1) then the parent work will be tagged as "Má vlast", not "Má vlast, JB 1:112". This frequently looks better, but not always, particularly if the level 0 work name does not contain all the parent work detail. If the full structure is not implicit in the level 0 name then a warning will be logged and written to the "warning" tag.
\n" "Strategy for setting style: It is suggested that you start with "extended/enhanced" style and the "Consistent with lowest level work description" as the source (this is the default). If this does not give acceptable results, try switching to "Full MusicBrainz work hierarchy". If the "enhanced" details in curly brackets (from the track title) give odd results then switch the style to "canonical works" only. Any remaining oddities are then probably in the MusicBrainz data, which may require editing.
")) self.works_source_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "There are 3 options for determing the principal source of the works metadata
- "Use only metadata from title text". The plugin will attempt to extract the hierarchy of works from the track title by looking for repetitions and patterns. If the title does not contain all the work names in the hierarchy then obviously this will limit what can be provided.
- "Use only metadata from canonical works". The hierarchy in the MB database will be used. Assuming the work is correctly entered in MB, this should provide all the data. However the text may differ from the track titles and will be the same for all recordings. It may also be in the language of the composer whereas the titles will be in the language of the release.
- "Use canonical work metadata enhanced with title text". This supplements the canonical data with text from the titles where it is significantly different. The supplementary data will be in curly brackets. This is clearly the most complete metadata style of the three but may lead to long descriptions. It is particularly useful for providing translations - see image below for an example (using the Muso library manager).
")) self.works_source_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Works source")) self.cwp_titles.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Use only metadata from title text". The plugin will attempt to extract the hierarchy of works from the track title by looking for repetitions and patterns. If the title does not contain all the work names in the hierarchy then obviously this will limit what can be provided.
")) self.cwp_titles.setText(_translate("ClassicalExtrasOptionsPage", "Use only metadata from title text")) self.cwp_works.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Use only metadata from canonical works". The hierarchy in the MB database will be used. Assuming the work is correctly entered in MB, this should provide all the data. However the text may differ from the track titles and will be the same for all recordings. It may also be in the language of the composer whereas the titles will probably be in the language of the release. (This language issue can also be addressed by using aliases - see below).
")) self.cwp_works.setText(_translate("ClassicalExtrasOptionsPage", "Use only metadata from canonical works")) self.cwp_extended.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Use canonical work metadata enhanced with title text". This supplements the canonical data with text from the titles **where it is significantly different**. The supplementary title data will be in curly brackets. This is clearly the most complete metadata style of the three but may lead to long descriptions. It is particularly useful for providing translations
")) self.cwp_extended.setText(_translate("ClassicalExtrasOptionsPage", "Use canonical work metadata enhanced with title text")) self.source_of_canonical_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Where either of the second two options above are chosen, there is a further choice to be made:
- "Full MusicBrainz work hierarchy". The names of each level of work are used to populate the relevant tags. I.e. if "Má vlast: I. Vyšehrad, JB 1:112/1" (level 0) is part of "Má vlast, JB 1:112" (level 1) then the parent work will be tagged as "Má vlast, JB 1:112", not "Má vlast". So, while accurate, this option might be more verbose.
- "Consistent with lowest level work description". The names of the level 0 work are used to populate the relevant tags. I.e. if "Má vlast: I. Vyšehrad, JB 1:112/1" (level 0) is part of "Má vlast, JB 1:112" (level 1) then the parent work will be tagged as "Má vlast", not "Má vlast, JB 1:112". This frequently looks better, but not always, particularly if the level 0 work name does not contain all the parent work detail. If the full structure is not implicit in the level 0 name then a warning will be logged and written to the "warning" tag.
")) self.source_of_canonical_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Source of canonical work text (if applicable)")) self.cwp_hierarchical_works.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Full MusicBrainz work hierarchy". The names of each level of work are used to populate the relevant tags. E.g. if "Má vlast: I. Vyšehrad, JB 1:112/1" (level 0) is part of "Má vlast, JB 1:112" (level 1) then the parent work will be tagged as "Má vlast, JB 1:112", not "Má vlast". So, while accurate, this option might be more verbose.
")) self.cwp_hierarchical_works.setText(_translate("ClassicalExtrasOptionsPage", "Full MusicBrainz work hierarchy (may be more verbose)")) self.cwp_level0_works.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Consistent with lowest level work description". The names of the level 0 work are used to populate the relevant tags. I.e. if "Má vlast: I. Vyšehrad, JB 1:112/1" (level 0) is part of "Má vlast, JB 1:112" (level 1) then the parent work will be tagged as "Má vlast", not "Má vlast, JB 1:112". This frequently looks better, but not always, particularly if the level 0 work name does not contain all the parent work detail. If the full structure is not implicit in the level 0 name then a warning will be logged and written to the "warning" tag.
")) self.cwp_level0_works.setText(_translate("ClassicalExtrasOptionsPage", "Consistent with lowest level work description (may be less verbose, but not always complete)")) self.cwp_derive_works_from_title.setText(_translate("ClassicalExtrasOptionsPage", "Attempt to get works and movement info from title if there are no work relationships? (Requires title in form \"work: movement\")")) self.work_aliases_label.setText(_translate("ClassicalExtrasOptionsPage", "Aliases (NB Use a consistent approach throughout the library otherwise duplicate works may occur - see the Readme)*
")) self.work_aliases_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Replace work names by aliases" will use primary aliases for the chosen locale instead of standard MusicBrainz work names. To choose the locale, use the drop-down under "translate artist names" in the main Picard Options-->Metadata page. Note that this option is not saved as a file tag since, if different choices are made for different releases, different work names may be stored and therefore cannot be grouped together in your player/library manager.
The sub-options allow either the replacement of all work names, where a primary alias exists, just the replacement of work names which are in non-Latin script, or only replace those which are flagged with user "Folksonomy" tags. The tag text needs to be included in the text box, in which case flagged works will be \'aliased\' as well as non-Latin script works, if the second sub-option is chosen. Note that the tags may either be anyone\'s tags ("Look in all tags") or the user\'s own tags. If selecting "Look in user\'s own tags only" you must be logged in to your MusicBrainz user account (in the Picard Options->General page), otherwise repeated dialogue boxes may be generated and you may need to force restart Picard.
")) self.replace_MBworknames_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Replace MB work names?")) self.cwp_aliases.setToolTip(_translate("ClassicalExtrasOptionsPage", "Use primary aliases for the chosen locale instead of standard MusicBrainz work names.
")) self.cwp_aliases.setText(_translate("ClassicalExtrasOptionsPage", "Replace work names by aliases. Select method -->")) self.cwp_no_aliases.setText(_translate("ClassicalExtrasOptionsPage", "Do not replace work names")) self.what_to_replace_outer_box.setTitle(_translate("ClassicalExtrasOptionsPage", "What to replace?")) self.cwp_aliases_all.setText(_translate("ClassicalExtrasOptionsPage", "All work names")) self.cwp_aliases_greek.setText(_translate("ClassicalExtrasOptionsPage", "Non-latin work names")) self.cwp_aliases_tagged.setText(_translate("ClassicalExtrasOptionsPage", "Only tagged works")) self.works_alias_tags_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Tags (\"Folksonomy\") identifying works to be replaced by aliases")) self.cwp_aliases_tags_all.setText(_translate("ClassicalExtrasOptionsPage", "Look in all tags")) self.cwp_aliases_tags_user.setText(_translate("ClassicalExtrasOptionsPage", "Look in user\'s own tags only (MUST BE LOGGED IN!)")) self.cwp_aliases_tag_text.setToolTip(_translate("ClassicalExtrasOptionsPage", "Separate multiple tags by commas
")) self.work_parts_tags_label.setText(_translate("ClassicalExtrasOptionsPage", "Tags to create - Use commas to separate multiple tags or leave blank to omit
")) self.works_parts_tags_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "Separate multiple tags by commas.
")) self.works_parts_tags_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Tags to create" sets the names of the tags that will be created from the sources described above. All these tags will be blanked before filling as specified. Tags specified against more than one source will have later sources appended in the sequence specified, separated by separators as specified.
")) self.works_tags_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Work tags")) self.label_40.setText(_translate("ClassicalExtrasOptionsPage", "Separator")) self.label_11.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Some software (notably Muso) can display a 2-level work hierarchy as well as the work-movement hierarchy. This tag can be use to store the 2-level work name (a double colon :: is used to separate the levels within the tag).
")) self.label_11.setText(_translate("ClassicalExtrasOptionsPage", "Tags for Work - for software with 2-level capability (e.g. Muso)
")) self.cwp_multi_work_sep.setItemText(1, _translate("ClassicalExtrasOptionsPage", "; ")) self.cwp_multi_work_sep.setItemText(2, _translate("ClassicalExtrasOptionsPage", ": ")) self.cwp_multi_work_sep.setItemText(3, _translate("ClassicalExtrasOptionsPage", ". ")) self.cwp_multi_work_sep.setItemText(4, _translate("ClassicalExtrasOptionsPage", ", ")) self.cwp_multi_work_sep.setItemText(5, _translate("ClassicalExtrasOptionsPage", "- ")) self.label.setText(_translate("ClassicalExtrasOptionsPage", "(In this format, intermediate works will be displayed after a double colon :: )")) self.label_15.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Software which can display a movement and work (but no higher levels) could use any tags specified here. Note that if there are multiple work levels, the intermediate levels will not be tagged. Users wanting all the information should use the tags from the previous option (but it may cause some breaks in the display if levels change) - alternatively the missing work levels can be included in a movement tag (see below).
")) self.label_15.setText(_translate("ClassicalExtrasOptionsPage", "Tags for Work - for software with 1-level capability (e.g. iTunes)
")) self.cwp_single_work_sep.setItemText(1, _translate("ClassicalExtrasOptionsPage", "; ")) self.cwp_single_work_sep.setItemText(2, _translate("ClassicalExtrasOptionsPage", ": ")) self.cwp_single_work_sep.setItemText(3, _translate("ClassicalExtrasOptionsPage", ". ")) self.cwp_single_work_sep.setItemText(4, _translate("ClassicalExtrasOptionsPage", ", ")) self.cwp_single_work_sep.setItemText(5, _translate("ClassicalExtrasOptionsPage", "- ")) self.label_2.setText(_translate("ClassicalExtrasOptionsPage", "(Intermediate works will not be displayed:- Either 1. use the 2-level format if you wish to display them, but note that this will ceate new work, or 2. include them in the movement [see below])")) self.label_12.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "This is the top-level work held in MB. This can be useful for cataloguing and searching (if the library software is capable). Note that this will always be the "canonical" MB name, not one derived from titles or the lowest level work name and that no annotations (e.g. key or work year) will be added. However, if "replace work names by aliases" has been selected and is applicable, the relevant alias will be used.
")) self.label_12.setText(_translate("ClassicalExtrasOptionsPage", "Tags for top-level (canonical) work (for capable library managers)
")) self.label_10.setText(_translate("ClassicalExtrasOptionsPage", " N/A ")) self.parts_tags_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Movement/Part tags")) self.label_13.setToolTip(_translate("ClassicalExtrasOptionsPage", "The Picard standard tag is \'movementnumber\' - include that or other(s) of your choice
")) self.label_13.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "This is not necessarily the embedded movt/part number, but is the sequence number of the movement within its parent work on the current release.
")) self.label_13.setText(_translate("ClassicalExtrasOptionsPage", "Tags for (computed) movement number (Picard std tag is movementnumber)
")) self.cwp_movt_no_sep.setItemText(1, _translate("ClassicalExtrasOptionsPage", "; ")) self.cwp_movt_no_sep.setItemText(2, _translate("ClassicalExtrasOptionsPage", ": ")) self.cwp_movt_no_sep.setItemText(3, _translate("ClassicalExtrasOptionsPage", ". ")) self.cwp_movt_no_sep.setItemText(4, _translate("ClassicalExtrasOptionsPage", ", ")) self.cwp_movt_no_sep.setItemText(5, _translate("ClassicalExtrasOptionsPage", "- ")) self.label_43.setToolTip(_translate("ClassicalExtrasOptionsPage", "The Picard tag \'movementtotal\' will be populated in any case - no need to specify it
")) self.label_43.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "This is not necessarily the total number of movements in the parent work, but is the total number of movement tracks within the parent work on the current release.
")) self.label_43.setText(_translate("ClassicalExtrasOptionsPage", "Tags for (computed) total number of movements (Picard std tag is movementtotal)
")) self.label_79.setText(_translate("ClassicalExtrasOptionsPage", " N/A ")) self.label_49.setText(_translate("ClassicalExtrasOptionsPage", "Movement name tags (Picard std tag is movement)
Use different movement tags if required for different level systems ==>
for use with multi-level work tags
for use with1-level work tags (intermediate works will prefix movement)
The Picard standard tag is \'movement\' - include that or other(s) of your choice
")) self.label_14.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "As below, but without the movement part/number prefix (if applicable)
")) self.label_14.setText(_translate("ClassicalExtrasOptionsPage", "Tags for Movement - excluding embedded movt/part numbers
")) self.label_39.setText(_translate("ClassicalExtrasOptionsPage", " N/A ")) self.label_9.setToolTip(_translate("ClassicalExtrasOptionsPage", "The Picard standard tag is \'movement\' - include that or other(s) of your choice
")) self.label_9.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "This tag(s) will contain the full lowest-level part name extracted from the lowest-level work name, according to the chosen tagging style.
")) self.label_9.setText(_translate("ClassicalExtrasOptionsPage", "Tags for Movement - including embedded movt/part numbers
")) self.label_37.setText(_translate("ClassicalExtrasOptionsPage", " N/A ")) self.partial_arrangements_medleys_label.setText(_translate("ClassicalExtrasOptionsPage", "Partial recordings, arrangements and medleys
")) self.partial_arrangements_medleys_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter text - do not use any quotation marks
")) self.label_20.setText(_translate("ClassicalExtrasOptionsPage", "N.B. If these options are selected or deselected, quit and restart Picard before proceeding")) self.partial_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "If this option is selected, partial recordings will be treated as a sub-part of the whole recording and will have the related text included in its name. Note that this text is at the end of the canonical name, but the latter will probably be stripped from the sub-part as it duplicates the recording work name; any title text will be appended to the whole. Note that, if "Consistent with lowest level work description" is chosen in section 2, the text may be treated as a "prefix" similar to those in the "Advanced" tab. If this eliminates other similar prefixes and has unwanted effects, then either change the desired text slightly (e.g. surround with brackets) or use the "Full MusicBrainz work hierarchy" option in section 2.
")) self.partial_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Partial recordings")) self.cwp_partial.setText(_translate("ClassicalExtrasOptionsPage", "Show partial recordings as separate sub-part, labelled with ->")) self.arrangements_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "If this option is selected, works which are arrangements of other works will have the latter treated in the same manner as "parent" works, except that the arrangement work name will be prefixed by the text provided.
")) self.arrangements_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Arrangements")) self.cwp_arrangements.setText(_translate("ClassicalExtrasOptionsPage", "Show arrangements as parts of original works, labelled with ->")) self.medleys_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Medleys")) self.cwp_medley.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Medleys
These can occur in two ways in MusicBrainz: (a) the recording is described as a "medley of" a number of works and (b) the track is described as (more than one) "medley including a recording of" a work. In the first case, the specified text will be included in brackets after the work name, whereas in the second case, the track will be treated as a recording of multiple works and the specified text will appear in the parent work name.
SongKong-compatible tag usage
")) self.songkong_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "See "What\'s this"
")) self.songkong_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Use work tags on file (no look up on MB) if Use Cache selected": This will enable the existing work tags on the file to be used in preference to looking up on MusicBrainz, if those tags are SongKong-compatible (which should be the case if SongKong has been used or if the SongKong tags have been previously written by this plugin). If present, this can speed up processing considerably, but obviously any new data on MusicBrainz will be missed. For the option to operate, "Use cache" also needs to be selected. Although faster, some of the subtleties of a full look-up will be missed - for example, parent works which are arrangements will not be highlighted as such, some arrangers or composers of original works may be omitted and some medley information may be missed. **In general, therefore, the use of this option will result in poorer metadata than allowing the full database look-up to run. It is not recommended unless speed is more important than quality.**
"Write SongKong-compatible work tags" does what it says. These can then be used by the previous option, if the release is subsequently reloaded into Picard, to speed things up (assuming the reload was not to pick up new work data).
Note that Picard and SongKong use the tag musicbrainz_workid to mean different things. If Picard has overwritten the SongKong tag (not a problem if this plugin is used) then a warning will be given and the works will be looked up on MusicBrainz. Also note that once a release is loaded, subsequent refreshes will use the cache (if option is ticked) in preference to the file tags.
")) self.cwp_use_sk.setText(_translate("ClassicalExtrasOptionsPage", "Use work tags on file (no look up on MB) if Use Cache selected* (NOT RECOMMENDED - SEE README)")) self.cwp_write_sk.setText(_translate("ClassicalExtrasOptionsPage", "Write SongKong-compatible work tags*")) self.label_82.setText(_translate("ClassicalExtrasOptionsPage", "* ASTERISKED OPTIONS ARE NOT SAVED IN FILE TAGS")) self.tabWidget.setTabText(self.tabWidget.indexOf(self.Works), _translate("ClassicalExtrasOptionsPage", "Works and parts")) self.genre_tag_label.setText(_translate("ClassicalExtrasOptionsPage", "Genre tags
")) self.label_73.setText(_translate("ClassicalExtrasOptionsPage", "Name of genre tag")) self.label_74.setText(_translate("ClassicalExtrasOptionsPage", "Name of sub-genre tag")) self.classical_genre_label.setText(_translate("ClassicalExtrasOptionsPage", ""Classical" genre
")) self.cwp_genres_classical_exclude.setText(_translate("ClassicalExtrasOptionsPage", "Exclude the text \"classical\" from main genre tag even if listed above")) self.cwp_genres_classical_selective.setText(_translate("ClassicalExtrasOptionsPage", "Make track \"classical\" only if there is a classical-specific genre (or do nothing if there is no filter)")) self.cwp_muso_classical.setText(_translate("ClassicalExtrasOptionsPage", "Use Muso composer list to determine if classical*")) self.cwp_genres_classical_all.setText(_translate("ClassicalExtrasOptionsPage", "Make all tracks \"classical\"")) self.label_64.setText(_translate("ClassicalExtrasOptionsPage", "Write a flag with text =")) self.label_71.setText(_translate("ClassicalExtrasOptionsPage", " in the following tag if the track is classical")) self.cwp_genres_arranger_as_composer.setText(_translate("ClassicalExtrasOptionsPage", "(Treat arrangers as for composers)")) self.label_81.setText(_translate("ClassicalExtrasOptionsPage", "* ASTERISKED OPTIONS ARE NOT SAVED IN FILE TAGS")) self.cwp_use_muso_refdb.setText(_translate("ClassicalExtrasOptionsPage", "Use Muso reference database (default path is set on \"advanced\" tab)*")) self.instruments_keys_label.setText(_translate("ClassicalExtrasOptionsPage", "Instruments and keys
")) self.instruments_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Instruments")) self.label_66.setText(_translate("ClassicalExtrasOptionsPage", "Tag name for instruments (will hold all instruments for a track)")) self.instruments_source_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Name sources to use for instruments (select at least one, otherwise no instruments will be included in tag)")) self.cwp_instruments_MB_names.setText(_translate("ClassicalExtrasOptionsPage", "MusicBrainz standard names")) self.cwp_instruments_credited_names.setText(_translate("ClassicalExtrasOptionsPage", "\"Credited-as\" names")) self.keys_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Keys")) self.label_72.setText(_translate("ClassicalExtrasOptionsPage", "Tag name for key(s)")) self.keys_include_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Include key(s) in work name?")) self.cwp_key_never_include.setText(_translate("ClassicalExtrasOptionsPage", "Never")) self.cwp_key_contingent_include.setText(_translate("ClassicalExtrasOptionsPage", "Only if key not already mentioned in work name")) self.cwp_key_include.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Include key(s) in work names" gives the option to include the key signature for a work in brackets after the name of the work in the metadata. Keys will be added in the appropriate levels: e.g. Dvořák\'s New World Symphony will get (E minor) at the work level, but only movements with different keys will be annotated viz. "II. Largo (D-flat major, C-Sharp minor)"
")) self.cwp_key_include.setText(_translate("ClassicalExtrasOptionsPage", "Always")) self.allowed_filters_label.setText(_translate("ClassicalExtrasOptionsPage", "Allowed genres (filter)
")) self.cwp_genres_filter.setText(_translate("ClassicalExtrasOptionsPage", "Only apply genres to tags if they match pre-defined names:")) self.genre_filters_frame.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Explanation of genre-matching:
Only genres matching those in the boxes will be placed in the genre or sub-genre tags.
If there is a matching genre found in the "classical main genres" or "classical sub-genres" box, then the track will be treated as being classical.
")) self.classical_genres_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Classical genres (i.e. specific to classical music) - List separated by commas")) self.label_60.setText(_translate("ClassicalExtrasOptionsPage", "Main genres:")) self.label_75.setText(_translate("ClassicalExtrasOptionsPage", "Sub-genres:")) self.cwp_muso_genres.setToolTip(_translate("ClassicalExtrasOptionsPage", "Select this to use the "classical genres" in Muso options as the "Main classical genres" here.
")) self.cwp_muso_genres.setText(_translate("ClassicalExtrasOptionsPage", "Use Muso classical genres*")) self.general_genres_box.setTitle(_translate("ClassicalExtrasOptionsPage", "General genres (may be associated with classical music, but not necessarily, e.g. \"instrumental\") - List separated by commas")) self.label_62.setText(_translate("ClassicalExtrasOptionsPage", "Main genres")) self.label_76.setText(_translate("ClassicalExtrasOptionsPage", "Sub-genres")) self.label_80.setText(_translate("ClassicalExtrasOptionsPage", "Genre name to use if none of the above main genres apply (leave blank if not required)")) self.label_77.setText(_translate("ClassicalExtrasOptionsPage", "List genres, separated by commas. Only those genres listed will be included in tags.")) self.label_78.setText(_translate("ClassicalExtrasOptionsPage", "See \"what\'s this\" for more details.")) self.source_of_genres_label.setText(_translate("ClassicalExtrasOptionsPage", "Source of genres - Note: if "existing file tag" is selected, information from the tag "genre" and the genre tag name specified above (if different) will be used
")) self.cwp_genres_use_file.setToolTip(_translate("ClassicalExtrasOptionsPage", "NB: This will use the contents of the file tag with the name given above (usually \'genre\').
")) self.cwp_genres_use_file.setText(_translate("ClassicalExtrasOptionsPage", "Existing file tag (see note above)")) self.cwp_genres_use_folks.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "This will use the folksonomy tags for works as a possible source of genres (if they match one of the lists below).
To use the folksonomy tags for releases/tracks, select the main Picard option in Options->Metadata->"Use folksonomy tags as genre". Again (unlike vanilla Picard) they will only be used by this plugin if they match one of the lists below.
")) self.cwp_genres_use_folks.setText(_translate("ClassicalExtrasOptionsPage", "Folksonomy work tags")) self.cwp_genres_use_worktype.setText(_translate("ClassicalExtrasOptionsPage", "Work-type")) self.cwp_genres_infer.setText(_translate("ClassicalExtrasOptionsPage", "Infer from artist metadata")) self.label_109.setText(_translate("ClassicalExtrasOptionsPage", "Periods and dates
")) self.dates_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Work dates")) self.dates_box_inner.setTitle(_translate("ClassicalExtrasOptionsPage", "Source of work dates for above tag")) self.cwp_workdate_source_composed.setText(_translate("ClassicalExtrasOptionsPage", "Composed date (or parent composed date)")) self.cwp_workdate_source_published.setText(_translate("ClassicalExtrasOptionsPage", "Published date")) self.cwp_workdate_source_premiered.setText(_translate("ClassicalExtrasOptionsPage", "Premiered date")) self.cwp_workdate_use_first.setText(_translate("ClassicalExtrasOptionsPage", "Use first available of above (in listed order)")) self.cwp_workdate_use_all.setText(_translate("ClassicalExtrasOptionsPage", "Include all sources")) self.cwp_workdate_annotate.setText(_translate("ClassicalExtrasOptionsPage", "Annotate dates using source name")) self.label_68.setText(_translate("ClassicalExtrasOptionsPage", "Tag name for work date")) self.cwp_workdate_include.setWhatsThis(_translate("ClassicalExtrasOptionsPage", ""Includeworkdate in work names" gives the option to include the \'work year\' for a work in brackets after the name of the work in the metadata. Dates (years) will be added in the appropriate levels: e.g. Smetana\'s \'Má vlast\' will get (1874-1879) at the work level, but the movements with different dates will be annotated viz. "Vyšehrad, JB 1:112/1 (1874)". If the dates are the same, there should be no repitetion at the movement level. (Work dates will be used in preference order, i.e. composed - published - premiered, with only the first available date being shown).
")) self.cwp_workdate_include.setText(_translate("ClassicalExtrasOptionsPage", "Include workdate in work name (in preference order listed above, with no annotation)")) self.periods_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Periods")) self.label_69.setText(_translate("ClassicalExtrasOptionsPage", "Tag name for period")) self.cwp_muso_periods.setText(_translate("ClassicalExtrasOptionsPage", "Use Muso map*")) self.cwp_muso_dates.setText(_translate("ClassicalExtrasOptionsPage", "Use Muso composer dates (if no work date) to determine period*")) self.label_70.setText(_translate("ClassicalExtrasOptionsPage", "Period map:")) self.period_map_annotation_label.setText(_translate("ClassicalExtrasOptionsPage", " (Period name, Start year, End year; Period name2, ... etc.) - periods may overlap [Do not use commas or semi-colons within period name]")) self.cwp_periods_arranger_as_composer.setText(_translate("ClassicalExtrasOptionsPage", "(Treat arrangers as for composers)")) self.label_119.setText(_translate("ClassicalExtrasOptionsPage", "N.B. At least one of the first two tabs (Artists: "Create extra artist metadata", or Works and parts: "Include all work levels") must be enabled for this section to run.
(Functionality will be reduced unless both the first two tabs are enabled.)
N.B. At least one of the first two tabs (Artists: "Create extra artist metadata", or Works and parts: "Include all work levels") must be enabled for this section to run.
")) self.label_97.setText(_translate("ClassicalExtrasOptionsPage", "Initial tag processing
")) self.initial_tag_processing_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Any tags specified in the next two rows will be blanked before applying the tag sources described in the following section. NB this applies only to Picard-generated tags, not to other tags which might pre-exist on the file: to blank those, use the main Options->Tags page. Comma-separate the tag names within the rows and note that these names are case-sensitive.
")) self.tags_to_blank.setTitle(_translate("ClassicalExtrasOptionsPage", "Remove Picard-generated tags before applying subsequent actions? (NB existing LOCAL FILE tags will remain unless cleared using standard Picard options - to remove these, overwrite them in the next section)")) self.label_3.setText(_translate("ClassicalExtrasOptionsPage", "Picard-generated tags to blank (comma-separated, case-sensitive):
")) self.cea_blank_tag.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter file tag names, separated by commas
")) self.cea_blank_tag_2.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter file tag names, separated by commas
")) self.label_19.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "List existing file tags which will be appended to rather than over-written by tag mapping (this will keep tags even if "Clear existing tags" is selected on main options)
NB To allow appending to happen, do not also include these tags in "Preserve tags" on the main options.
Enter file tag names, separated by commas
")) self.cea_keep.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "This refers to the tags which already exist on files which have been matched to MusicBrainz in the right-hand panel, not the tags generated by Picard from the MusicBrainz database. Normally, Picard cannot process these tags - either it will overwrite them (if it creates a similarly named tag), clear them (if \'Clear existing tags\' is specified in the main Options->Tags screen) or keep them (if \'Preserve these tags...\' is specified after the \'Clear existing tags\' option). Classical Extras allows a further option - for the tags to be appended to in the tag mapping section (see below). List file tags which will be appended to rather than over-written by tag mapping (NB this will keep tags even if "Clear existing tags" is selected on main options). In addition, certain existing tags may be used by Classical Extras - in particular genre-related tags (see the Genres etc. options tab for more).
Note that if "Split lyrics tag" is specified (see the Artists tab), then the tag named there will be included in the \'Keep file tags\' list and does not need to be added in this section.
")) self.cea_clear_tags.setToolTip(_translate("ClassicalExtrasOptionsPage", "Note that the main Picard option "Clear existing tags" should be unchecked for this option to operate in preference to that Picard option. The difference is that this option will not intefere with cover art, whereas the main Picard option will remove previous cover art.
")) self.cea_clear_tags.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "If selected: the bottom pane of Picard will only show tags which have been generated from the MusicBrainz lookups plus any existing file tags which are listed above or in the main options "Preserve tags...".
This does not mean that the file tags will be removed when saving the file. For that to happen, "Clear existing tags" needs to be selected in the main options.
")) self.cea_clear_tags.setText(_translate("ClassicalExtrasOptionsPage", "Do not show any file tags that are NOT listed above AND NOT listed in the main Picard \"Preserve tags...\" option (Options->Tags), even if \"Clear existing tags\" is not selected.")) self.label_98.setText(_translate("ClassicalExtrasOptionsPage", "Tag map details
")) self.tagmap_details_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter tags, separated by commas.
")) self.textBrowser.setHtml(_translate("ClassicalExtrasOptionsPage", "\n" "\n" "Notes:
\n" "Click "Source from:" button to edit source tags.
\n" "Any valid Picard-generated tag can be entered in the "source" box, as well as Classical Extras sources, and mapped into other tags - not just restricted to artists.
\n" "To put a constant in a tag, type it into the source box preceded by a backslash \\.
\n" "In all cases, the source will be APPENDED to the Picard tag. To replace the standard tag, first blank it in the section above - add it back later in the list below if required (e.g. artist -> artist).
\n" "BUT note that any existing LOCAL FILE tag will be replaced by (not appended with) any Picard/Classical Extras tag UNLESS specified in the list box above.
\n" "These tag-mapping options may be omitted from the over-riding of artist options - see advanced tab
\n" "For more help seethe readme.
")) self.toolButton_1.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_1.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_1.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_1.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_1.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_1.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_1.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_1.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_1.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_1.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_1.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_1.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_1.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_1.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_1.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_1.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_1.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_1.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_1.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_1.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_1.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_1.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_1.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_21.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_1.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_1.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_2.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_2.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_2.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_2.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_2.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_2.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_2.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_2.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_2.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_2.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_2.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_2.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_2.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_2.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_2.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_2.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_2.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_2.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_2.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_2.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_2.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_2.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_2.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_23.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_2.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_2.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_3.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_3.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_3.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_3.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_3.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_3.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_3.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_3.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_3.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_3.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_3.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_3.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_3.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_3.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_3.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_3.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_3.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_3.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_3.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_3.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_3.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_3.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_3.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_25.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_3.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_3.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_4.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_4.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_4.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_4.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_4.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_4.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_4.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_4.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_4.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_4.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_4.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_4.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_4.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_4.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_4.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_4.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_4.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_4.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_4.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_4.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_4.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_4.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_4.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_27.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_4.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_4.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_5.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_5.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_5.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_5.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_5.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_5.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_5.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_5.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_5.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_5.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_5.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_5.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_5.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_5.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_5.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_5.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_5.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_5.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_5.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_5.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_5.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_5.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_5.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_29.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_5.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_5.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_6.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_6.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_6.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_6.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_6.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_6.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_6.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_6.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_6.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_6.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_6.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_6.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_6.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_6.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_6.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_6.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_6.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_6.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_6.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_6.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_6.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_6.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_6.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_31.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_6.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_6.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_7.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_7.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_7.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_7.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_7.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_7.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_7.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_7.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_7.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_7.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_7.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_7.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_7.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_7.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_7.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_7.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_7.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_7.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_7.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_7.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_7.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_7.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_7.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_33.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_7.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_7.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_8.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_8.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_8.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_8.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_8.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_8.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_8.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_8.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_8.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_8.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_8.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_8.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_8.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_8.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_8.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_8.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_8.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_8.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_8.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_8.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_8.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_8.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_8.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_35.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_8.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_8.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_9.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_9.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_9.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_9.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_9.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_9.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_9.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_9.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_9.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_9.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_9.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_9.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_9.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_9.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_9.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_9.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_9.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_9.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_9.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_9.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_9.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_9.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_9.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_53.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_9.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_9.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_10.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_10.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_10.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_10.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_10.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_10.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_10.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_10.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_10.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_10.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_10.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_10.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_10.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_10.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_10.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_10.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_10.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_10.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_10.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_10.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_10.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_10.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_10.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_55.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_10.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_10.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_11.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_11.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_11.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_11.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_11.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_11.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_11.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_11.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_11.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_11.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_11.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_11.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_11.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_11.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_11.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_11.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_11.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_11.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_11.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_11.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_11.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_11.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_11.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_57.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_11.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_11.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_12.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_12.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_12.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_12.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_12.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_12.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_12.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_12.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_12.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_12.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_12.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_12.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_12.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_12.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_12.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_12.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_12.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_12.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_12.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_12.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_12.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_12.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_12.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_59.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_12.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_12.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_13.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_13.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_13.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_13.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_13.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_13.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_13.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_13.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_13.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_13.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_13.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_13.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_13.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_13.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_13.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_13.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_13.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_13.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_13.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_13.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_13.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_13.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_13.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_61.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_13.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_13.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_14.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_14.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_14.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_14.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_14.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_14.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_14.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_14.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_14.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_14.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_14.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_14.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_14.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_14.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_14.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_14.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_14.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_14.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_14.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_14.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_14.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_14.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_14.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_63.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_14.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_14.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_15.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_15.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_15.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_15.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_15.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_15.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_15.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_15.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_15.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_15.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_15.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_15.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_15.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_15.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_15.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_15.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_15.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_15.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_15.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_15.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_15.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_15.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_15.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_65.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_15.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_15.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.toolButton_16.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click to edit sources")) self.toolButton_16.setText(_translate("ClassicalExtrasOptionsPage", "Source from:")) self.cea_source_16.setToolTip(_translate("ClassicalExtrasOptionsPage", "Click button to edit. See notes above.")) self.cea_source_16.setItemText(1, _translate("ClassicalExtrasOptionsPage", "album_soloists, album_conductors, album_ensembles")) self.cea_source_16.setItemText(2, _translate("ClassicalExtrasOptionsPage", "soloists, conductors, ensembles, album_composers, composers")) self.cea_source_16.setItemText(3, _translate("ClassicalExtrasOptionsPage", "album_soloists")) self.cea_source_16.setItemText(4, _translate("ClassicalExtrasOptionsPage", "album_conductors")) self.cea_source_16.setItemText(5, _translate("ClassicalExtrasOptionsPage", "album_ensembles")) self.cea_source_16.setItemText(6, _translate("ClassicalExtrasOptionsPage", "album_composers")) self.cea_source_16.setItemText(7, _translate("ClassicalExtrasOptionsPage", "album_composer_lastnames")) self.cea_source_16.setItemText(8, _translate("ClassicalExtrasOptionsPage", "soloists")) self.cea_source_16.setItemText(9, _translate("ClassicalExtrasOptionsPage", "soloist_names")) self.cea_source_16.setItemText(10, _translate("ClassicalExtrasOptionsPage", "ensembles")) self.cea_source_16.setItemText(11, _translate("ClassicalExtrasOptionsPage", "ensemble_names")) self.cea_source_16.setItemText(12, _translate("ClassicalExtrasOptionsPage", "composers")) self.cea_source_16.setItemText(13, _translate("ClassicalExtrasOptionsPage", "arrangers")) self.cea_source_16.setItemText(14, _translate("ClassicalExtrasOptionsPage", "orchestrators")) self.cea_source_16.setItemText(15, _translate("ClassicalExtrasOptionsPage", "conductors")) self.cea_source_16.setItemText(16, _translate("ClassicalExtrasOptionsPage", "chorusmasters")) self.cea_source_16.setItemText(17, _translate("ClassicalExtrasOptionsPage", "leaders")) self.cea_source_16.setItemText(18, _translate("ClassicalExtrasOptionsPage", "support_performers")) self.cea_source_16.setItemText(19, _translate("ClassicalExtrasOptionsPage", "work_type")) self.cea_source_16.setItemText(20, _translate("ClassicalExtrasOptionsPage", "release")) self.label_67.setText(_translate("ClassicalExtrasOptionsPage", "into tags:")) self.cea_tag_16.setToolTip(_translate("ClassicalExtrasOptionsPage", "Enter comma-separated list of tags")) self.cea_cond_16.setText(_translate("ClassicalExtrasOptionsPage", "Conditional?")) self.label_42.setText(_translate("ClassicalExtrasOptionsPage", "(If source is empty, tag will be left unchanged) ")) self.label_17.setText(_translate("ClassicalExtrasOptionsPage", "(Conditional tags will only be filled if previously empty)")) self.cea_tag_sort.setToolTip(_translate("ClassicalExtrasOptionsPage", "Select to include sort-tags, where available. See "What\'s this?" for more details.
")) self.cea_tag_sort.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "If a sort tag is associated with the source tag then the sort names will be placed in a sort tag corresponding to the destination tag. Note that the only explicit sort tags written by Picard are for artist, albumartist and composer. Piacrd also writes hidden variables \'_artists_sort\' and \'albumartists_sort\' (note the plurals - these are the sort tags for multi-valued alternatives \'artists\' and \'_albumartists\'). To be consistent with this approach, the plugin writes hidden variables for other tags - e.g. \'_arranger_sort\'. The plugin also writes hidden sort variables for the various hidden artist variables - e.g. \'_cwp_librettists\' has a matching sort variable \'_cwp_librettists_sort\'. Therefore most artist-type sources will have a sort tag/variable associated with them and these will be placed in a destination sort tag if this option is selected - in other words, selecting this option will cause most destination tags to have associated sort tags. Furthermore, any hidden sort variables associated with tags which are not listed explicitly in the tag mapping section will also be written out as tags (i.e. even if the related tags are not included as destination tags). Note, however, that composite sources (e.g. " ensemble_names + \\; + conductors") do not have sort tags associated with them.
If this option is not selected, no additional sort tags will be written, but the hidden variables will still be available, so if a sort tag is required explicitly, just map the sort tag directly - e.g. map \'conductors_sort\' to \'conductor_sort\'.
")) self.cea_tag_sort.setText(_translate("ClassicalExtrasOptionsPage", "Also populate sort tags")) self.tabWidget.setTabText(self.tabWidget.indexOf(self.Tag_mapping), _translate("ClassicalExtrasOptionsPage", "Tag mapping")) self.label_110.setText(_translate("ClassicalExtrasOptionsPage", "General
")) self.ce_no_run.setText(_translate("ClassicalExtrasOptionsPage", "Do not run Classical Extras for tracks where no pre-existing file is detected (warning tag will be written)")) self.advanced_artists_label.setText(_translate("ClassicalExtrasOptionsPage", "Artists (only effective if \"Artists\" section enabled)
")) self.advanced_artists_box.setToolTip(_translate("ClassicalExtrasOptionsPage", "Separate multiple names by commas. Do not use any quotation marks.
")) self.advanced_artists_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Permits the listing of strings by which ensembles of different types may be identified. This is used by the plugin to place performer details in the relevant hidden variables and thus make them available for use in the "Tag mapping" tab as sources for any required tags.
If it is important that only whole words are to be matched, be sure to include a space after the string.
")) self.ensemble_strings_label.setText(_translate("ClassicalExtrasOptionsPage", "Ensemble strings (separate names by commas)
")) self.cea_orchestras_2.setText(_translate("ClassicalExtrasOptionsPage", "Orchestras")) self.cea_choirs_2.setText(_translate("ClassicalExtrasOptionsPage", "Choirs")) self.cea_groups_2.setText(_translate("ClassicalExtrasOptionsPage", "Groups (i.e. other ensembles such as quartets etc.)")) self.advanced_workparts_label.setText(_translate("ClassicalExtrasOptionsPage", "Works and parts (only effective if \"Works and parts\" section enabled)
")) self.label_4.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "Sometimes MB lookups fail. Unfortunately Picard (currently) has no automatic "retry" function. The plugin will attempt to retry for the specified number of attempts. If it still fails, the hidden variable _cwp_error will be set with a message; if error logging is checked in section 4, an error message will be written to the log and the contents of _cwp_error will be written out to a special tag called "An_error_has_occurred" which should appear prominently in the bottom pane of Picard. The problem may be resolved by refreshing, otherwise there may be a problem with the MB database availability. It is unlikely to be a software problem with the plugin.
")) self.label_4.setText(_translate("ClassicalExtrasOptionsPage", "Max number of re-tries to access works (in case of server errors)*
")) self.label_120.setText(_translate("ClassicalExtrasOptionsPage", "Allow blank part names for arrangements and part recordings if arrangement/partial label is provided
")) self.label_114.setText(_translate("ClassicalExtrasOptionsPage", "Removal of common text between parent and child works
")) self.label_90.setText(_translate("ClassicalExtrasOptionsPage", "Minimum number of similar words required before eliminating. Use zero for no elimination.
(Punctuation and accents etc. will be ignored in word comparison)
NB Parent name text at the start of a work which is followed by punctuation in the work name will always be stripped regardless of this setting.
Synonyms in the next section also apply.
This subsection contains various parameters affecting the processing of strings in titles. Because titles are free-form, not all circumstances can be anticipated. Detailed documentation of these is beyond the scope of this Readme as the effects can be quite complex and subtle and may require an understanding of the plugin code (which is of course open-source) to acsertain them. If pure canonical works are used ("Use only metadata from canonical works" and, if necessary, "Full MusicBrainz work hierarchy" on the Works and parts tab, section 2) then this processing should be irrelevant, but no text from titles will be included. Some explanations are given below:
"Proximity of new words". When using extended metadata - i.e. "metadata enhanced with title text", the plugin will attempt to remove similar words between the canonical work name (in MusicBrainz) and the title before extending the canonical name. After removing such words, a rather "bitty" result may occur. To avoid this, any new words with the specified proximity will have the words between them (or up to the end) included even if they repeat words in the work name.
"Prefixes". When using "metadata from titles" or extended metadata, the structure of the works in MusicBrainz is used to infer the structure in the title text, so strings that are repeated between tracks which are part of the same MusicBrainz work will be treated as "higher level". This can lead to anomolies if, for instance, the titles are "Work name: Part 1", "Work name: Part 2", "Part" will be treated as part of the parent work name. Specifying such words in "Prefixes" will prevent this.
"Synonyms". These words will be considered equivalent when comparing work name and title text. Thus if one word appears in the work name, that and its synonym will be removed from the title in extending the metadata (subject to the proximity setting above).
"Replacements". These words/phrases will be replaced in the title text in extended metadata, regardless of the text in the work name.
")) self.label_115.setText(_translate("ClassicalExtrasOptionsPage", "How title metadata should be included in extended metadata (use cautiously - read documentation)
(Mostly only applies if "Use canonical work metadata enhanced with title text" selected on "Works and parts" tab. However synonyms also apply to parent/child text removal.)
Proximity of new words (to each other) to trigger in-fill with existing words (default = 2)
")) self.label_7.setText(_translate("ClassicalExtrasOptionsPage", "Proximity of new words (to start or end) to trigger in-fill with existing words (default =1)
")) self.label_5.setText(_translate("ClassicalExtrasOptionsPage", "Treat hyphenated words as two words for comparison purposes
")) self.label_93.setText(_translate("ClassicalExtrasOptionsPage", "Proportion of a string to be matched to a (usually larger) string for it to be considered essentially similar (default = 66%)
")) self.cwp_substring_match.setSuffix(_translate("ClassicalExtrasOptionsPage", "%")) self.label_87.setText(_translate("ClassicalExtrasOptionsPage", "\n" "\n" "Fill part name with title text if it would otherwise have no text other than arrangement or partial annotations
")) self.label_92.setText(_translate("ClassicalExtrasOptionsPage", "Prepositions/conjunctions and prefixes
DO NOT USE ANY COMMAS OR QUOTE MARKS (apostophes in words are acceptable)
Prepositions & conjunctions: these are words that will not be regarded as providing additional information (not treated as \'new\' words) unless they precede a new word.
Use lower case only, comma separated
Prefixes to be ignored in comparison (case insensitive, comma separated)
To prevent a prefix from being ignored when extending metadata with title info, precede it with a space.
To ensure only whole words are removed, follow with a space.
Separate multiple names by commas. Do not use any quotation marks.
")) self.cwp_synonyms_2.setText(_translate("ClassicalExtrasOptionsPage", "Synonyms and replacements - must be written as tuples separated by forward slashes - e.g (a,b) / (c,d,e) - a tuple may have two or more synonyms.
N.B. The matching is case-insensitive. Roman numerals will be treated as synonyms of arabic numerals in any event, so no need to enter these.
The last member of the tuple should be the canonical form (i.e. the one to which others are converted for comparison or replacement) and must be a normal string (not a regex). See readme for full details.
Unless entering a regular expression, use backslash \\ to escape any regex metacharacters, namely \\ ^ $ . | ? * + ( ) [ ] {
Also escape commas , and forward slashes /. Do not enclose strings in quote marks.
Enter SYNONYM tuples below - each item in a tuple will be treated as similar when comparing works/parts and titles. The text in tags will be unaltered.
")) self.label_16.setText(_translate("ClassicalExtrasOptionsPage", "Enter REMOVALS/REPLACEMENTS below - these will result in the "extended" text in tags being changed
Put the word(s), phrase(s), or regular exprerssion(s) in the first part(s) of the tuple. The replacement text (or nothing - to remove) goes in the last member of the tuple.
N.B. Replacement text will operate BEFORE synonyms are considered.
")) self.cwp_replacements.setToolTip(_translate("ClassicalExtrasOptionsPage", "Entries must be 2-tuples, e.g. (Replace this, with this). Separate multiple tuples by forward slash. Do not use any quotation marks. Spaces are acceptable. The first item of a tuple may be a regular expression - enclose it with double exclamation marks - e.g.(!!regex here!!, replacement text here).
")) self.advanced_genres_label.setText(_translate("ClassicalExtrasOptionsPage", "Genres etc. (only required if Muso-specific options are used for genres/periods)
")) self.label_85.setText(_translate("ClassicalExtrasOptionsPage", "Path to Muso reference database:")) self.label_84.setText(_translate("ClassicalExtrasOptionsPage", "Name of Muso reference database")) self.label_86.setText(_translate("ClassicalExtrasOptionsPage", "RESTART PICARD AFTER CHANGING THESE
")) self.label_117.setText(_translate("ClassicalExtrasOptionsPage", "Logging options*
")) self.logging_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "These options are in addition to the options chosen in Picard\'s "Help->View error/debug log" settings. They only affect messages written by this plugin. To enable debug messages to be shown, the flag needs to be set here and "Debug mode" needs to be turned on in the log. It is strongly advised to keep the "debug" and "info" flags unchecked unless debugging is required as they slow up processing significantly and may even cause Picard to crash on large releases. The "error" and "warning" flags should be left checked, unless it is required to suppress messages written out to tags (the default is to write messages to the tags 001_errors and 002_warnings).
")) self.log_error.setText(_translate("ClassicalExtrasOptionsPage", "Error")) self.log_warning.setText(_translate("ClassicalExtrasOptionsPage", "Warning")) self.log_debug.setText(_translate("ClassicalExtrasOptionsPage", "Debug")) self.custom_logging_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Custom logging")) self.log_basic.setText(_translate("ClassicalExtrasOptionsPage", "Basic")) self.log_info.setText(_translate("ClassicalExtrasOptionsPage", "Full")) self.label_118.setText(_translate("ClassicalExtrasOptionsPage", "Classical Extras Special Tags
")) self.save_options_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "This can be used so that the user has a record of the version of Classical Extras which generated the tags and which options were selected to achieve the resulting tags. Note that the tags will be blanked first so this will only show the last options used on a particular file. The same tag can be used for both sets of options, resulting in a multi-valued tag.
")) self.save_options_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Save plugin details and options in a tag?*")) self.label_41.setText(_translate("ClassicalExtrasOptionsPage", "Tag name for plugin version")) self.label_36.setText(_translate("ClassicalExtrasOptionsPage", "Tag name for artist/mapping/misc. options")) self.label_38.setText(_translate("ClassicalExtrasOptionsPage", "Tag name for work/genre options")) self.override_box.setWhatsThis(_translate("ClassicalExtrasOptionsPage", "If options have previously been saved (see above), selecting these will cause the saved options to be used in preference to the displayed options. The displayed options will not be affected and will be used if no saved options are present. The default is for no over-ride. If artist options over-ride is chosen, then tag map detail options may be included or not in the override.
The last checkbox, "Overwrite options in Options Pages", is for VERY CAREFUL USE ONLY. It will cause any options read from the saved tags (if the relevant box has been ticked) to over-write the options on the plugin Options Page UI. The intended use of this is if for some reason the user\'s preferred options have been erased/reverted to default - by using this option, the previously-used choices from a reliable filed album can be used to populate the Options Page. The box will automatically be unticked after loading/refreshing one album, to prevent inadvertant use. Far better is to make a backup copy of the picard.ini file.
")) self.override_box.setTitle(_translate("ClassicalExtrasOptionsPage", "Over-ride plugin options displayed in Options Pages with options from local file tags (previously saved using method in box above)?*")) self.cea_override.setText(_translate("ClassicalExtrasOptionsPage", "Artist options")) self.cwp_override.setText(_translate("ClassicalExtrasOptionsPage", "Work options")) self.ce_genres_override.setText(_translate("ClassicalExtrasOptionsPage", "Genres etc. options")) self.ce_tagmap_override.setToolTip(_translate("ClassicalExtrasOptionsPage", "Will not over-ride displayed options unless artist options over-ride is also selected
")) self.ce_tagmap_override.setText(_translate("ClassicalExtrasOptionsPage", "Tag mapping options")) self.ce_options_overwrite.setText(_translate("ClassicalExtrasOptionsPage", "Overwrite options in Options Pages (READ WARNINGS in Readme)")) self.label_121.setText(_translate("ClassicalExtrasOptionsPage", "Note that the above saved options include the related \"advanced\" options on this tab as well as the options on each of the main tabs.")) self.ce_show_ui_tags.setText(_translate("ClassicalExtrasOptionsPage", "Show additional tags in Picard UI (rhs panel) - N.B. RESTART NEEDED FOR CHANGE TO TAKE EFFECT")) self.groupBox.setTitle(_translate("ClassicalExtrasOptionsPage", "Additional columns for Picard UI to show specific tags*")) self.label_94.setText(_translate("ClassicalExtrasOptionsPage", "RESTART PICARD AFTER CHANGING THESE - otherwise changes will not take effect
")) self.label_8.setText(_translate("ClassicalExtrasOptionsPage", "Notes:
1. Use the format column_name_A: (include_tag_1, include_tag_2) / column_name_B: include_tag_3 etc. (i.e. put multiple tags to be concatenated in brackets)
2. To just flag tags that have changed, rather than show the contents, add _DIFF at the end of the tag name
3. If more than one tag name is included for a column, then:
(a) if the tags are _DIFF tags, then the column will be flagged if any of them have changed
(b) otherwise the tag contents will be concatenated
General description
\n" "Classical Extras provides tagging enhancements for Picard and, in particular, utilises MusicBrainz’s hierarchy of works to provide work/movement tags. All options are set through a user interface in Picard options->plugins. This interface provides separate sections to enhance artist/performer tags, works and parts, genres and also allows for a generalised "tag mapping" (simple scripting). While it is designed to cater for the complexities of classical music tagging, it may also be useful for other music which has more than just basic song/artist/album data.
The options screen provides five tabs for users to control the tags produced:
1. Artists: Options as to whether artist tags will contain standard MB names, aliases or as-credited names. Ability to include and annotate names for specialist roles (chorus master, arranger, lyricist etc.). Ability to read lyrics tags on the file which has been loaded and assign them to track and album levels if required. (Note: Picard will not normally process incoming file tags).
2. Works and parts: The plugin will build a hierarchy of works and parts (e.g. Work -> Part -> Movement or Opera -> Act -> Number) based on the works in MusicBrainz\'s database. These can then be displayed in tags in a variety of ways according to user preferences. Furthermore partial recordings, medleys, arrangements and collections of works are all handled according to user choices. There is a processing overhead for this at present because MusicBrainz limits look-ups to one per second.
3. Genres etc.: Options are available to customise the source and display of information relating to genres, instruments, keys, work dates and periods. Additional capabilities are provided for users of Muso (or others who provide the relevant XML files) to use pre-existing databases of classical genres, classical composers and classical periods.
4. Tag mapping: in some ways, this is a simple substitute for some of Picard\'s scripting capability. The main advantage is that the plugin will remember what tag mapping you use for each release (or even track).
5. Advanced: Various options to control the detailed processing of the above.
All user options can be saved on a per-album (or even per-track) basis so that tweaks can be used to deal with inconsistencies in the MusicBrainz data (e.g. include English titles from the track listing where the MusicBrainz works are in the composer\'s language and/or script). Also existing file tags can be processed (not possible in native Picard).
Please see my website for full details of this plugin and how to use it.
\n" "This help page now has only general information.
\n" "There are extensive tooltips and "What\'s This" popups (right click for them)
")) self.tabWidget.setTabText(self.tabWidget.indexOf(self.Help), _translate("ClassicalExtrasOptionsPage", "Help")) ================================================ FILE: plugins/classicdiscnumber/classicdiscnumber.py ================================================ PLUGIN_NAME = 'Classic Disc Numbers' PLUGIN_AUTHOR = 'Lukas Lalinsky' PLUGIN_DESCRIPTION = '''Moves disc numbers and subtitles from the separate tags to album titles.''' PLUGIN_VERSION = "0.2" PLUGIN_API_VERSIONS = ["0.15", "2.0"] from picard.metadata import register_track_metadata_processor import re def add_discnumbers(tagger, metadata, track, release): if int(metadata["totaldiscs"] or "0") > 1: if "discsubtitle" in metadata: metadata["album"] = "%s (disc %s: %s)" % ( metadata["album"], metadata["discnumber"], metadata["discsubtitle"]) else: metadata["album"] = "%s (disc %s)" % ( metadata["album"], metadata["discnumber"]) register_track_metadata_processor(add_discnumbers) ================================================ FILE: plugins/collect_artists/collect_artists.py ================================================ PLUGIN_NAME = "Collect Album Artists" PLUGIN_AUTHOR = "johbi" PLUGIN_DESCRIPTION = "Adds a context menu shortcut to collect all track artists from a release and format them as the releases album artist." PLUGIN_VERSION = '0.1' PLUGIN_API_VERSIONS = ['2.1', '2.2'] PLUGIN_LICENSE = "GPL-3.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl.txt" from picard.album import Album from picard.ui.itemviews import BaseAction, register_album_action class CollectArtists(BaseAction): NAME = 'Replace release artist with &track artists...' def callback(self, objs): for album in objs: if isinstance(album, Album): trackartists = [] for track in album.tracks: if "artists" in track.metadata: artists = track.metadata.getall("artists") elif "artist" in track.metadata: artists = track.metadata.getall("artist") else: continue for artist in artists: if artist not in trackartists: trackartists.append(artist) if len(trackartists) >= 2: albumartist = (", ").join(trackartists[:-1]) + ' & ' + trackartists[-1] elif len(trackartists) == 1: albumartist = trackartists[0] else: self.tagger.window.set_statusbar_message("Could not find any artists for the album: \"" + album.metadata['album'] + "\"") continue album.metadata.set("albumartist", albumartist) for track in album.tracks: track.metadata.set("albumartist", albumartist) for files in track.linked_files: track.update_file_metadata(files) album.update() register_album_action(CollectArtists()) ================================================ FILE: plugins/compatible_TXXX/compatible_TXXX.py ================================================ # -*- coding: utf-8 -*- PLUGIN_NAME = u"Compatible TXXX frames" PLUGIN_AUTHOR = u'Tungol' PLUGIN_DESCRIPTION = """This plugin improves the compatibility of ID3 tags \ by using only a single value for TXXX frames. Multiple value TXXX frames \ technically don't comply with the ID3 specification.""" PLUGIN_VERSION = "0.1" PLUGIN_API_VERSIONS = ["2.0"] PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from picard import config from picard.formats import register_format from picard.formats.id3 import MP3File, TrueAudioFile, DSFFile, AiffFile from mutagen import id3 id3v24_join_with = '; ' def build_compliant_TXXX(self, encoding, desc, values): """Return a TXXX frame with only a single value. Use id3v23_join_with as the sperator if using id3v2.3, otherwise the value set in this plugin (default "; "). """ if config.setting['write_id3v23']: sep = config.setting['id3v23_join_with'] else: sep = id3v24_join_with joined_values = [sep.join(values)] return id3.TXXX(encoding=encoding, desc=desc, text=joined_values) # I can't actually remove the original MP3File et al formats once they're # registered. This depends on the name of the replacements sorting after the # name of the originals, because picard.formats.guess_format picks the last # item from a sorted list. class MP3FileCompliant(MP3File): """Alternate MP3 format class which uses single-value TXXX frames.""" build_TXXX = build_compliant_TXXX class TrueAudioFileCompliant(TrueAudioFile): """Alternate TTA format class which uses single-value TXXX frames.""" build_TXXX = build_compliant_TXXX class DSFFileCompliant(DSFFile): """Alternate DSF format class which uses single-value TXXX frames.""" build_TXXX = build_compliant_TXXX class AiffFileCompliant(AiffFile): """Alternate AIFF format class which uses single-value TXXX frames.""" build_TXXX = build_compliant_TXXX register_format(MP3FileCompliant) register_format(TrueAudioFileCompliant) register_format(DSFFileCompliant) register_format(AiffFileCompliant) ================================================ FILE: plugins/critiquebrainz/critiquebrainz.py ================================================ # -*- coding: utf-8 -*- # Critiquebrainz plugin for Picard # Copyright (C) 2022 Tobias Sarner # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # PLUGIN_NAME = 'Critiquebrainz Review Comment' PLUGIN_AUTHOR = 'Tobias Sarner' PLUGIN_DESCRIPTION = '''Uses Critiquebrainz for comment as review or rating. WARNING: Experimental plugin. All guarantees voided by use. Example: Taylor Swift Release: Midnights https://musicbrainz.org/release/e348fdd6-f73b-47fe-94c4-670bfee26a39 , https://critiquebrainz.org/release-group/0dcc84fb-c592-46e9-ba92-a52bb44dd553 Recording: https://musicbrainz.org/recording/93113326-93e9-409c-a3d6-5ec91864ba30 , https://critiquebrainz.org/recording/93113326-93e9-409c-a3d6-5ec91864ba30''' PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt" PLUGIN_VERSION = "1.0.2" PLUGIN_API_VERSIONS = ["2.0"] from functools import partial from picard import log from picard.metadata import register_album_metadata_processor from picard.metadata import register_track_metadata_processor from picard.webservice import ratecontrol from picard.util import load_json CRITIQUEBRAINZ_HOST = "critiquebrainz.org" CRITIQUEBRAINZ_PORT = 80 ratecontrol.set_minimum_delay((CRITIQUEBRAINZ_HOST, CRITIQUEBRAINZ_PORT), 50) def result_review(album, metadata, data, reply, error): if error: album._requests -= 1 album._finalize_loading(None) return try: reviews = load_json(data).get("reviews") if reviews: for review in reviews: if "last_revision" in review: ident = (review["entity_type"].replace("_", "-") + "_review_" + review["published_on"] + "_" + review["user"]["display_name"] + "_" + review["language"]).lower() if review["last_revision"]["text"] is not None: review_text = review["last_revision"]["text"] + "\n\nEine Bewertung mit " + str(review["last_revision"]["rating"]) + " von 5 Sternen veröffentlich durch " + review["user"]["display_name"] + " am " + review["published_on"] + " lizensiert unter " + review["full_name"] + "."; if "comment:" + ident in metadata: metadata["comment:" + ident] = review_text else: metadata.add("comment:" + ident, review_text) review_url = "https://critiquebrainz.org/review/" + review["id"] if "website:" + ident in metadata: metadata["website:" + ident] = review_url else: metadata.add("website:" + ident, review_url) log.debug("%s: success parsing %s reviews (%s) from response", PLUGIN_NAME, reviews["count"], metadata["albumtitle"]) except Exception as e: log.error("%s: error parsing review (%s) from response: %s", PLUGIN_NAME, metadata["albumtitle"], str(e)) finally: album._requests -= 1 album._finalize_loading(None) def process_releasegroup(album, metadata, release): queryargs = { "entity_id": metadata["musicbrainz_releasegroupid"], "entity_type": "release_group" } album.tagger.webservice.get( CRITIQUEBRAINZ_HOST, CRITIQUEBRAINZ_PORT, "/ws/1/review", partial(result_review, album, metadata), priority=True, parse_response_type=None, queryargs=queryargs ) album._requests += 1 def process_recording(album, metadata, track, release): queryargs = { "entity_id": metadata["musicbrainz_recordingid"], "entity_type": "recording" } album.tagger.webservice.get( CRITIQUEBRAINZ_HOST, CRITIQUEBRAINZ_PORT, "/ws/1/review", partial(result_review, album, metadata), priority=True, parse_response_type=None, queryargs=queryargs ) album._requests += 1 register_album_metadata_processor(process_releasegroup) register_track_metadata_processor(process_recording) ================================================ FILE: plugins/cuesheet/cuesheet.py ================================================ # -*- coding: utf-8 -*- PLUGIN_NAME = "Generate Cuesheet" PLUGIN_AUTHOR = "Lukáš Lalinský, Sambhav Kothari" PLUGIN_DESCRIPTION = "Generate cuesheet (.cue file) from an album." PLUGIN_VERSION = "1.2.2" PLUGIN_API_VERSIONS = ["2.0"] import os.path import re from PyQt5 import QtCore, QtWidgets from picard.util import find_existing_path, encode_filename from picard.ui.itemviews import BaseAction, register_album_action _whitespace_re = re.compile(r'\s', re.UNICODE) _split_re = re.compile(r'\s*("[^"]*"|[^ ]+)\s*', re.UNICODE) def msfToMs(msf): msf = msf.split(":") return ((int(msf[0]) * 60 + int(msf[1])) * 75 + int(msf[2])) * 1000 / 75 class CuesheetTrack(list): def __init__(self, cuesheet, index): list.__init__(self) self.cuesheet = cuesheet self.index = index def set(self, *args): self.append(args) def find(self, prefix): return [i for i in self if tuple(i[:len(prefix)]) == tuple(prefix)] def getTrackNumber(self): return self.index def getLength(self): try: nextTrack = self.cuesheet.tracks[self.index + 1] index0 = self.find(("INDEX", "01")) index1 = nextTrack.find(("INDEX", "01")) return msfToMs(index1[0][2]) - msfToMs(index0[0][2]) except IndexError: return 0 def getField(self, prefix): try: return self.find(prefix)[0][len(prefix)] except IndexError: return "" def getArtist(self): return self.getField(("PERFORMER",)) def getTitle(self): return self.getField(("TITLE",)) def setArtist(self, artist): found = False for item in self: if item[0] == "PERFORMER": if not found: item[1] = artist found = True else: del item if not found: self.append(("PERFORMER", artist)) artist = property(getArtist, setArtist) class Cuesheet(object): def __init__(self, filename): self.filename = filename self.tracks = [] def read(self): with open(encode_filename(self.filename)) as f: self.parse(f.readlines()) def unquote(self, string): if string.startswith('"'): if string.endswith('"'): return string[1:-1] else: return string[1:] return string def quote(self, string): if _whitespace_re.search(string): return '"' + string.replace('"', '\'') + '"' return string def parse(self, lines): track = CuesheetTrack(self, 0) self.tracks = [track] isUnicode = False for line in lines: # remove BOM if line.startswith('\xfe\xff'): isUnicode = True line = line[1:] # decode to unicode string line = line.strip() if isUnicode: line = line.decode('UTF-8', 'replace') else: line = line.decode('ISO-8859-1', 'replace') # parse the line split = [self.unquote(s) for s in _split_re.findall(line)] keyword = split[0].upper() if keyword == 'TRACK': trackNum = int(split[1]) track = CuesheetTrack(self, trackNum) self.tracks.append(track) track.append(split) def write(self): lines = [] for track in self.tracks: num = track.index for line in track: indent = 0 if num > 0: if line[0] == "TRACK": indent = 2 elif line[0] != "FILE": indent = 4 line2 = " ".join([self.quote(s) for s in line]) line2 = " " * indent + line2 + "\n" lines.append(line2.encode("UTF-8")) with open(encode_filename(self.filename), "wb") as f: f.writelines(lines) class GenerateCuesheet(BaseAction): NAME = "Generate &Cuesheet..." def callback(self, objs): album = objs[0] current_directory = self.config.persist["current_directory"] or QtCore.QDir.homePath() current_directory = find_existing_path(str(current_directory)) filename, selected_format = QtWidgets.QFileDialog.getSaveFileName( None, "", current_directory, "Cuesheet (*.cue)") if filename: cuesheet = Cuesheet(filename) #try: cuesheet.read() #except IOError: pass while len(cuesheet.tracks) <= len(album.tracks): track = CuesheetTrack(cuesheet, len(cuesheet.tracks)) cuesheet.tracks.append(track) #if len(cuesheet.tracks) > len(album.tracks) - 1: # cuesheet.tracks = cuesheet.tracks[0:len(album.tracks)+1] t = cuesheet.tracks[0] t.set("PERFORMER", album.metadata["albumartist"]) t.set("TITLE", album.metadata["album"]) t.set("REM", "MUSICBRAINZ_ALBUM_ID", album.metadata["musicbrainz_albumid"]) t.set("REM", "MUSICBRAINZ_ALBUM_ARTIST_ID", album.metadata["musicbrainz_albumartistid"]) if "date" in album.metadata: t.set("REM", "DATE", album.metadata["date"]) index = 0.0 for i, track in enumerate(album.tracks): mm = index / 60.0 ss = (mm - int(mm)) * 60.0 ff = (ss - int(ss)) * 75.0 index += track.metadata.length / 1000.0 t = cuesheet.tracks[i + 1] t.set("TRACK", "%02d" % (i + 1), "AUDIO") t.set("PERFORMER", track.metadata["artist"]) t.set("TITLE", track.metadata["title"]) t.set("REM", "MUSICBRAINZ_TRACK_ID", track.metadata["musicbrainz_trackid"]) t.set("REM", "MUSICBRAINZ_ARTIST_ID", track.metadata["musicbrainz_artistid"]) t.set("INDEX", "01", "%02d:%02d:%02d" % (mm, ss, ff)) for file in track.linked_files: audio_filename = file.filename extension = audio_filename.split(".")[-1].lower() if os.path.dirname(filename) == os.path.dirname(audio_filename): audio_filename = os.path.basename(audio_filename) if extension in ["mp3", "mp2", "m2a"]: file_type = "MP3" elif extension in ["aiff", "aif", "aifc"]: file_type = "AIFF" else: file_type = "WAVE" cuesheet.tracks[i].set("FILE", audio_filename, file_type) cuesheet.write() action = GenerateCuesheet() register_album_action(action) ================================================ FILE: plugins/decade/__init__.py ================================================ # -*- coding: utf-8 -*- # # Copyright (C) 2019 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. PLUGIN_NAME = 'Decade function' PLUGIN_AUTHOR = 'Philipp Wolfer' PLUGIN_DESCRIPTION = ('Add a $decade(date) function to get the decade ' 'from a year. E.g. $decade(1994-04-05) will give "90s". ' 'By default decades between 1920 and 2000 will be ' 'shortened to two digits. You can disable this with ' 'setting the second parameter to 0, e.g. ' '$decade(1994,0) will give "1990s".') PLUGIN_VERSION = "1.0" PLUGIN_API_VERSIONS = ["2.0", "2.1", "2.2", "2.3"] PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" from picard.script import register_script_function def decade(date, shorten=True): """ >>> decade("1970-09-18") '70s' >>> decade("1994") '90s' >>> decade("1994-04") '90s' >>> decade("1994-04-05") '90s' >>> decade("1994-04-05", shorten=False) '1990s' >>> decade("1901") '1900s' >>> decade("1917-08-22") '1910s' >>> decade("1921-10-10") '20s' >>> decade("1770-12-16") '1770s' >>> decade("2017-07-20") '2010s' >>> decade("2020") '2020s' >>> decade("992") '990s' >>> decade("992-01") '990s' >>> decade("") '' >>> decade(None) '' >>> decade("foo") '' """ try: year = int(date.split('-')[0]) except (AttributeError, ValueError): return "" decade = year // 10 * 10 if shorten and 1920 <= decade < 2000: decade -= 1900 return "%ds" % decade def script_decade(parser, value, shorten=True): return decade(value, shorten and shorten != '0') register_script_function(script_decade, name="decade") ================================================ FILE: plugins/decode_cyrillic/decode_cyrillic.py ================================================ # -*- coding: utf-8 -*- # This is the Decode Cyrillic plugin for MusicBrainz Picard. # Copyright (C) 2015 aeontech # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. from __future__ import print_function PLUGIN_NAME = "Decode Cyrillic" PLUGIN_AUTHOR = "aeontech" PLUGIN_DESCRIPTION = ''' This plugin helps you quickly convert mis-encoded cyrillic Windows-1251 tags to proper UTF-8 encoded strings. If your track/album names look something like "Àëèñà â ñò›àíå ÷óäåñ", run this plugin from the context menu before running the "Lookup" or "Scan" tools ''' PLUGIN_VERSION = "1.1" PLUGIN_API_VERSIONS = ["1.0", "2.0"] PLUGIN_LICENSE = "MIT" PLUGIN_LICENSE_URL = "https://opensource.org/licenses/MIT" from picard import log from picard.cluster import Cluster from picard.ui.itemviews import BaseAction, register_cluster_action _decode_tags = [ 'title', 'albumartist', 'artist', 'album', 'artistsort' ] # _from_encoding = "latin1" # _to_encoding = "cp1251" # TODO: # - extend to support multiple codepage decoding, not just cp1251->latin1 # instead, try the common variations, and show a dialog to the user, # allowing him to select the correct transcoding. See 2cyr.com for example. # - also see http://stackoverflow.com/questions/23326531/how-to-decode-cp1252-string class DecodeCyrillic(BaseAction): NAME = "Unmangle cyrillic metadata" def unmangle(self, tag, value): try: print(value, value.encode('latin1')) unmangled_value = value.encode('latin1').decode('cp1251') except UnicodeError: unmangled_value = value log.debug("%s: could not unmangle tag %s; original value: %s" % (PLUGIN_NAME, tag, value)) return unmangled_value def callback(self, objs): for cluster in objs: if not isinstance(cluster, Cluster): continue for tag in _decode_tags: if not (tag in cluster.metadata): continue cluster.metadata[tag] = self.unmangle(tag, cluster.metadata[tag]) log.debug("cluster name is %s by %s" % (cluster.metadata['album'], cluster.metadata['albumartist'])) for i, file in enumerate(cluster.files): log.debug("%s: Trying to unmangle file - original metadata %s" % (PLUGIN_NAME, file.orig_metadata)) for tag in _decode_tags: if not (tag in file.metadata): continue unmangled_tag = self.unmangle(tag, file.metadata[tag]) file.orig_metadata[tag] = unmangled_tag file.metadata[tag] = unmangled_tag file.orig_metadata.changed = True file.metadata.changed = True file.update(signal=True) cluster.update() register_cluster_action(DecodeCyrillic()) ================================================ FILE: plugins/decode_greek_cyrillic/decode_greek1253.py ================================================ # -*- coding: utf-8 -*- #This is not my work. I just changed the language to Greek. #All the credits goes to the original coder. # This is the Decode Greek plugin for MusicBrainz Picard. # Copyright (C) 2015 aeontech # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. from __future__ import print_function PLUGIN_NAME = "Decode Cyrillic Greek" PLUGIN_AUTHOR = "aeontech, Lefteris NeNpO" PLUGIN_VERSION = "1.3" PLUGIN_API_VERSIONS = ["1.0", "2.0"] PLUGIN_LICENSE = "MIT" PLUGIN_LICENSE_URL = "https://opensource.org/licenses/MIT" PLUGIN_DESCRIPTION = ''' This plugin helps you quickly convert mis-encoded Greek Windows-1253 tags to proper UTF-8 encoded strings. If your track/album names look something like "Àëèñà â ñò›àíå ÷óäåñ", run this plugin from the context menu before running the "Lookup" or "Scan" tools ''' from picard import log from picard.cluster import Cluster from picard.ui.itemviews import BaseAction, register_cluster_action _decode_tags = [ 'title', 'albumartist', 'albumartistsort', 'artist', 'artistsort', 'album', 'comment:', 'comment:ID3v1 Comment' ] class DecodeGreek(BaseAction): NAME = "Unmangle Greek metadata" def unmangle(self, tag, value): try: log.debug("%s: %s => %r" % (PLUGIN_NAME, value, value.encode('latin1'))) unmangled_value = value.encode('latin1').decode('cp1253') except UnicodeError: unmangled_value = value log.debug("%s: could not unmangle tag %s; original value: %s" % (PLUGIN_NAME, tag, value)) return unmangled_value def callback(self, objs): for cluster in objs: if not isinstance(cluster, Cluster): continue for tag in _decode_tags: if not (tag in cluster.metadata): continue cluster.metadata[tag] = self.unmangle(tag, cluster.metadata[tag]) log.debug("cluster name is %s by %s" % (cluster.metadata['album'], cluster.metadata['albumartist'])) for file in cluster.files: log.debug("%s: Trying to unmangle file - original metadata %s" % (PLUGIN_NAME, file.orig_metadata)) for tag in _decode_tags: if not (tag in file.metadata): continue unmangled_tag = self.unmangle(tag, file.metadata[tag]) file.orig_metadata[tag] = unmangled_tag file.metadata[tag] = unmangled_tag file.orig_metadata.changed = True file.metadata.changed = True file.update(signal=True) cluster.update() register_cluster_action(DecodeGreek()) ================================================ FILE: plugins/deezerart/__init__.py ================================================ PLUGIN_NAME = "Deezer cover art" PLUGIN_AUTHOR = "Fabio ForniIf this option is enabled, the fields albumsort and titlesort will be filled. If you are only interested in the scripting functions, then disable this to reduce the time it takes to load releases.
", None)) self.check_tagging.setText(QCoreApplication.translate("EnhancedTitlesOptions", u"Tag albumsort and titlesort fields", None)) self.label_2.setText(QCoreApplication.translate("EnhancedTitlesOptions", u"If this option is enabled, the plugin will first check if there are any sort names already available in MusicBrainz. Otherwise, it will swap the title's prefix directly. Enabling this option will increase the time it takes to load releases, especially for tracks.
", None)) self.check_album_aliases.setText(QCoreApplication.translate("EnhancedTitlesOptions", u"Check for album aliases", None)) self.check_track_aliases.setText(QCoreApplication.translate("EnhancedTitlesOptions", u"Check for track aliases", None)) # retranslateUi ================================================ FILE: plugins/fanarttv/__init__.py ================================================ # -*- coding: utf-8 -*- # # Copyright (C) 2015-2021 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. PLUGIN_NAME = 'fanart.tv cover art' PLUGIN_AUTHOR = 'Philipp Wolfer, Sambhav Kothari' PLUGIN_DESCRIPTION = ('Use cover art from fanart.tv.This plugin loads cover art from fanart.tv. If you want to improve the results of this plugin please contribute.
\n" "In order to use this plugin you have to register a personal API key on
https://fanart.tv/get-an-api-key/
- Title: "Unit 1 - Lesson 10"For example, take the following titles and track numbers:
- Title: "Unit 1 - Lesson 1" - Track #1 - Title: "Unit 1 - Lesson 2" - Track #1 - Title: "Unit 2 - Lesson 10" - Track #2 - Title: "Unit 2 - Lesson 1" - Track #2The track numbers will be changed to: 1, 2, 4, 3
These settings will determine the format for any Performer tags prepared. The format is divided into six parts: the performer; the instrument or "vocals"; and four user selectable sections for the extra information. This is set out as:
[Section 1]Instrument/Vocals[Section 2][Section 3]: Performer[Section 4]
You can select the section in which each of the extra information words appears.
For each of the sections you can select the starting character(s), the character(s) separating entries, and the ending character(s). Note that leading or trailing spaces must be included in the settings and will not be automatically added. If no separator characters are entered, the items within a section will be automatically separated by a single space.
Please visit the repository on GitHub for additional information.
")) self.gb_word_groups.setTitle(_translate("FormatPerformerTagsOptionsPage", "Keyword Sections Assignment")) self.group_additonal.setTitle(_translate("FormatPerformerTagsOptionsPage", "Keyword: additional")) self.additional_rb_1.setText(_translate("FormatPerformerTagsOptionsPage", "1")) self.additional_rb_2.setText(_translate("FormatPerformerTagsOptionsPage", "2")) self.additional_rb_3.setText(_translate("FormatPerformerTagsOptionsPage", "3")) self.additional_rb_4.setText(_translate("FormatPerformerTagsOptionsPage", "4")) self.group_guest.setTitle(_translate("FormatPerformerTagsOptionsPage", "Keyword: guest")) self.guest_rb_1.setText(_translate("FormatPerformerTagsOptionsPage", "1")) self.guest_rb_2.setText(_translate("FormatPerformerTagsOptionsPage", "2")) self.guest_rb_3.setText(_translate("FormatPerformerTagsOptionsPage", "3")) self.guest_rb_4.setText(_translate("FormatPerformerTagsOptionsPage", "4")) self.group_solo.setTitle(_translate("FormatPerformerTagsOptionsPage", "Keyword: solo")) self.solo_rb_1.setText(_translate("FormatPerformerTagsOptionsPage", "1")) self.solo_rb_2.setText(_translate("FormatPerformerTagsOptionsPage", "2")) self.solo_rb_3.setText(_translate("FormatPerformerTagsOptionsPage", "3")) self.solo_rb_4.setText(_translate("FormatPerformerTagsOptionsPage", "4")) self.group_vocals.setTitle(_translate("FormatPerformerTagsOptionsPage", "All vocal type keywords")) self.vocals_rb_1.setText(_translate("FormatPerformerTagsOptionsPage", "1")) self.vocals_rb_2.setText(_translate("FormatPerformerTagsOptionsPage", "2")) self.vocals_rb_3.setText(_translate("FormatPerformerTagsOptionsPage", "3")) self.vocals_rb_4.setText(_translate("FormatPerformerTagsOptionsPage", "4")) self.gb_group_settings.setTitle(_translate("FormatPerformerTagsOptionsPage", "Section Display Settings")) self.label_2.setText(_translate("FormatPerformerTagsOptionsPage", "Section 2")) self.label_3.setText(_translate("FormatPerformerTagsOptionsPage", "Section 3")) self.format_group_1_start_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.label.setText(_translate("FormatPerformerTagsOptionsPage", "Section 1")) self.label_4.setText(_translate("FormatPerformerTagsOptionsPage", "Section 4")) self.format_group_1_sep_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_1_end_char.setText(_translate("FormatPerformerTagsOptionsPage", " ")) self.format_group_1_end_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.label_5.setText(_translate("FormatPerformerTagsOptionsPage", "Start Char(s)")) self.label_6.setText(_translate("FormatPerformerTagsOptionsPage", "Sep Char(s)")) self.label_7.setText(_translate("FormatPerformerTagsOptionsPage", "End Char(s)")) self.format_group_2_start_char.setText(_translate("FormatPerformerTagsOptionsPage", ", ")) self.format_group_2_start_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_3_start_char.setText(_translate("FormatPerformerTagsOptionsPage", " (")) self.format_group_3_start_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_4_start_char.setText(_translate("FormatPerformerTagsOptionsPage", " (")) self.format_group_4_start_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_2_sep_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_3_sep_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_4_sep_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_2_end_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_3_end_char.setText(_translate("FormatPerformerTagsOptionsPage", ")")) self.format_group_3_end_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.format_group_4_end_char.setText(_translate("FormatPerformerTagsOptionsPage", ")")) self.format_group_4_end_char.setPlaceholderText(_translate("FormatPerformerTagsOptionsPage", "(blank)")) self.gb_examples.setTitle(_translate("FormatPerformerTagsOptionsPage", "Examples")) ================================================ FILE: plugins/format_performer_tags/ui_options_format_performer_tags.ui ================================================These are the original / replacement pairs used to map one genre entry to another. Each pair must be entered on a separate line in the form:
[genre match test string]=[replacement genre]
Unless the "regular expressions" option is enabled, supported wildcards in the test string part of the mapping include \'*\' and \'?\' to match any number of characters and a single character respectively. An example for mapping all types of Rock genres (e.g. Country Rock, Hard Rock, Progressive Rock) to "Rock" would be done using the following line:
*rock*=Rock
Blank lines and lines beginning with an equals sign (=) will be ignored. Case-insensitive tests are used when matching. Replacements will be made in the order they are found in the list.
For more information please see the User Guide on GitHub.
These are the original / replacement pairs used to modify the keys for the performer tags. Each pair must be entered on a separate line in the form:
original character string=replacement character string
Blank lines and lines beginning with an equals sign (=) will be ignored. Replacements will be made in the order they are found in the list. An example for removing "family" from instrument names would be done using the following two lines:
s family=ses
family=sNote that the second line begins with a single space.
For more information please see the User Guide on GitHub.
This plugin provides the ability to store and retrieve script variables that persist across tracks and albums. This allows things like finding and storing the earliest recording date of all of the tracks on an album.
There are two types of persistent variables maintained - album variables and session variables. Album variables persist across all tracks on an album. Each album's information is stored separately, and is reset when the album is refreshed. The information is cleared when an album is removed. Session variables persist across all albums and tracks, and are cleared when Picard is shut down or restarted.
This plugin adds eight new scripting functions to allow management of persistent script variables:
Please see the user guide on GitHub for more information.
''' PLUGIN_VERSION = '1.1' PLUGIN_API_VERSIONS = ['2.0', '2.1', '2.2', '2.3', '2.4', '2.6', '2.7'] PLUGIN_LICENSE = 'GPL-2.0-or-later' PLUGIN_LICENSE_URL = 'https://www.gnu.org/licenses/gpl-2.0.html' PLUGIN_USER_GUIDE_URL = 'https://github.com/rdswift/picard-plugins/blob/2.0_RDS_Plugins/plugins/persistent_variables/docs/README.md' from PyQt5 import QtWidgets from picard import log from picard.album import ( Album, register_album_post_removal_processor, ) from picard.file import File from picard.metadata import register_album_metadata_processor from picard.plugin import PluginPriority from picard.plugins.persistent_variables.ui_variables_dialog import ( Ui_VariablesDialog, ) from picard.script import register_script_function from picard.script.parser import normalize_tagname from picard.track import Track from picard.ui.itemviews import ( BaseAction, register_album_action, register_file_action, register_track_action, ) class PersistentVariables: album_variables = {} session_variables = {} @classmethod def clear_album_vars(cls, album): if album: cls.album_variables[album] = {} @classmethod def set_album_var(cls, album, key, value): if album: if album not in cls.album_variables: cls.clear_album_vars(album) if key: cls.album_variables[album][key] = value @classmethod def unset_album_var(cls, album, key): if album and album in cls.album_variables: cls.album_variables[album].pop(key, None) @classmethod def unset_album_dict(cls, album): if album: cls.album_variables.pop(album, None) @classmethod def get_album_var(cls, album, key): if album in cls.album_variables: return cls.album_variables[album][key] if key in cls.album_variables[album] else "" return "" @classmethod def clear_session_vars(cls): cls.session_variables = {} @classmethod def set_session_var(cls, key, value): if key: cls.session_variables[key] = value @classmethod def unset_session_var(cls, key): cls.session_variables.pop(key, None) @classmethod def get_session_var(cls, key): return cls.session_variables[key] if key in cls.session_variables else "" @classmethod def get_album_dict(cls, album): if album and album in cls.album_variables: return cls.album_variables[album] return {} @classmethod def get_session_dict(cls): return cls.session_variables def _get_album_id(parser): file = parser.file if file: if file.parent and hasattr(file.parent, 'album') and file.parent.album: return str(file.parent.album.id) else: return "" # Fall back to parser context to allow processing on albums newly retrieved from MusicBrainz return parser.context['musicbrainz_albumid'] def func_set_s(parser, name, value): if value: PersistentVariables.set_session_var(normalize_tagname(name), value) else: func_unset_s(parser, name) return "" def func_unset_s(parser, name): PersistentVariables.unset_session_var(normalize_tagname(name)) return "" def func_get_s(parser, name): return PersistentVariables.get_session_var(normalize_tagname(name)) def func_clear_s(parser): PersistentVariables.clear_session_vars() return "" def func_unset_a(parser, name): album_id = _get_album_id(parser) log.debug("{0}: Unsetting album '{1}' variable '{2}'".format(PLUGIN_NAME, album_id, normalize_tagname(name),)) if album_id: PersistentVariables.unset_album_var(album_id, normalize_tagname(name)) return "" def func_set_a(parser, name, value): album_id = _get_album_id(parser) log.debug("{0}: Setting album '{1}' persistent variable '{2}' to '{3}'".format(PLUGIN_NAME, album_id, normalize_tagname(name), value,)) if album_id: PersistentVariables.set_album_var(album_id, normalize_tagname(name), value) return "" def func_get_a(parser, name): album_id = _get_album_id(parser) log.debug("{0}: Getting album '{1}' persistent variable '{2}'".format(PLUGIN_NAME, album_id, normalize_tagname(name),)) if album_id: return PersistentVariables.get_album_var(album_id, normalize_tagname(name)) return "" def func_clear_a(parser): album_id = _get_album_id(parser) log.debug("{0}: Clearing album '{1}' persistent variables dictionary".format(PLUGIN_NAME, album_id,)) if album_id: PersistentVariables.clear_album_vars(album_id) return "" def initialize_album_dict(album, album_metadata, release_metadata): album_id = str(album.id) log.debug("{0}: Initializing album '{1}' persistent variables dictionary".format(PLUGIN_NAME, album_id,)) PersistentVariables.clear_album_vars(album_id) def destroy_album_dict(album): album_id = str(album.id) log.debug("{0}: Destroying album '{1}' persistent variables dictionary".format(PLUGIN_NAME, album_id,)) PersistentVariables.unset_album_dict(album_id) class ViewVariables(BaseAction): NAME = 'View persistent variables' def callback(self, objs): obj = objs[0] files = self.tagger.get_files_from_objects(objs) if files: obj = files[0] dialog = ViewVariablesDialog(obj) dialog.exec_() class ViewVariablesDialog(QtWidgets.QDialog): def __init__(self, obj, parent=None): QtWidgets.QDialog.__init__(self, parent) self.ui = Ui_VariablesDialog() self.ui.setupUi(self) self.ui.buttonBox.accepted.connect(self.accept) self.album_id = "" self.setWindowTitle("Persistent Variables") if isinstance(obj, Album): self.album_id = str(obj.id) if isinstance(obj, File): if obj.parent and hasattr(obj.parent, 'album') and obj.parent.album: self.album_id = str(obj.parent.album.id) elif isinstance(obj, Track): if obj.album: self.album_id = str(obj.album.id) album_dict = PersistentVariables.get_album_dict(self.album_id) album_count = len(album_dict) session_dict = PersistentVariables.get_session_dict() session_count = len(session_dict) table = self.ui.metadata_table key_example, value_example = self.get_table_items(table, 0) self.key_flags = key_example.flags() self.value_flags = value_example.flags() table.setRowCount(album_count + session_count + 2) i = 0 self.add_separator_row(table, i, "Album Variables", album_count) i += 1 for key in sorted(album_dict.keys()): key_item, value_item = self.get_table_items(table, i) key_item.setText(key) value_item.setText(album_dict[key]) i += 1 self.add_separator_row(table, i, "Session Variables", session_count) i += 1 for key in sorted(session_dict.keys()): key_item, value_item = self.get_table_items(table, i) key_item.setText(key) value_item.setText(session_dict[key]) i += 1 def add_separator_row(self, table, i, title, count): key_item, value_item = self.get_table_items(table, i) font = key_item.font() font.setBold(True) key_item.setFont(font) key_item.setText(title) value_item.setText("{0} item{1}".format(count, "" if count == 1 else "s",)) def get_table_items(self, table, i): key_item = table.item(i, 0) value_item = table.item(i, 1) if not key_item: key_item = QtWidgets.QTableWidgetItem() key_item.setFlags(self.key_flags) table.setItem(i, 0, key_item) if not value_item: value_item = QtWidgets.QTableWidgetItem() value_item.setFlags(self.value_flags) table.setItem(i, 1, value_item) return key_item, value_item viewer = ViewVariables() # Register the new functions register_script_function(func_set_a, name='set_a', documentation="""`$set_a(name,value)` Sets the album persistent variable `name` to `value`.""") register_script_function(func_unset_a, name='unset_a', documentation="""`$unset_a(name)` Unsets the album persistent variable `name`.""") register_script_function(func_get_a, name='get_a', documentation="""`$get_a(name)` Gets the album persistent variable `name`.""") register_script_function(func_clear_a, name='clear_a', documentation="""`$clear_a()` Clears all album persistent variables.""") register_script_function(func_set_s, name='set_s', documentation="""`$set_s(name,value)` Sets the session persistent variable `name` to `value`.""") register_script_function(func_unset_s, name='unset_s', documentation="""`$unset_s(name)` Unsets the session persistent variable `name`.""") register_script_function(func_get_s, name='get_s', documentation="""`$get_s(name)` Gets the session persistent variable `name`.""") register_script_function(func_clear_s, name='clear_s', documentation="""`$clear_s()` Clears all session persistent variables.""") # Register the processers register_album_metadata_processor(initialize_album_dict, priority=PluginPriority.HIGH) register_album_post_removal_processor(destroy_album_dict) # Register context actions register_file_action(viewer) register_track_action(viewer) register_album_action(viewer) ================================================ FILE: plugins/persistent_variables/ui_variables_dialog.py ================================================ # -*- coding: utf-8 -*- # Form based on that used for the "View Script Variables" plugin from PyQt5 import QtCore, QtGui, QtWidgets class Ui_VariablesDialog(object): def setupUi(self, VariablesDialog): VariablesDialog.setObjectName("VariablesDialog") VariablesDialog.resize(600, 450) self.verticalLayout = QtWidgets.QVBoxLayout(VariablesDialog) self.verticalLayout.setObjectName("verticalLayout") self.metadata_table = QtWidgets.QTableWidget(VariablesDialog) self.metadata_table.setAutoFillBackground(False) self.metadata_table.setSelectionMode(QtWidgets.QAbstractItemView.ContiguousSelection) self.metadata_table.setRowCount(1) self.metadata_table.setColumnCount(2) self.metadata_table.setObjectName("metadata_table") item = QtWidgets.QTableWidgetItem() font = QtGui.QFont() font.setBold(True) font.setWeight(75) item.setFont(font) self.metadata_table.setHorizontalHeaderItem(0, item) item = QtWidgets.QTableWidgetItem() font = QtGui.QFont() font.setBold(True) font.setWeight(75) item.setFont(font) self.metadata_table.setHorizontalHeaderItem(1, item) item = QtWidgets.QTableWidgetItem() item.setFlags(QtCore.Qt.ItemIsSelectable|QtCore.Qt.ItemIsEnabled) self.metadata_table.setItem(0, 0, item) item = QtWidgets.QTableWidgetItem() item.setFlags(QtCore.Qt.ItemIsSelectable|QtCore.Qt.ItemIsEnabled) self.metadata_table.setItem(0, 1, item) self.metadata_table.horizontalHeader().setDefaultSectionSize(150) self.metadata_table.horizontalHeader().setSortIndicatorShown(False) self.metadata_table.horizontalHeader().setStretchLastSection(True) self.metadata_table.verticalHeader().setVisible(False) self.metadata_table.verticalHeader().setDefaultSectionSize(20) self.metadata_table.verticalHeader().setMinimumSectionSize(20) self.verticalLayout.addWidget(self.metadata_table) self.buttonBox = QtWidgets.QDialogButtonBox(VariablesDialog) self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Ok) self.buttonBox.setObjectName("buttonBox") self.verticalLayout.addWidget(self.buttonBox) self.retranslateUi(VariablesDialog) QtCore.QMetaObject.connectSlotsByName(VariablesDialog) def retranslateUi(self, VariablesDialog): _translate = QtCore.QCoreApplication.translate item = self.metadata_table.horizontalHeaderItem(0) item.setText(_translate("VariablesDialog", "Variable")) item = self.metadata_table.horizontalHeaderItem(1) item.setText(_translate("VariablesDialog", "Value")) __sortingEnabled = self.metadata_table.isSortingEnabled() self.metadata_table.setSortingEnabled(False) self.metadata_table.setSortingEnabled(__sortingEnabled) ================================================ FILE: plugins/playlist/playlist.py ================================================ #!/usr/bin/python # -*- coding: utf-8 -*- # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. PLUGIN_NAME = "Generate M3U playlist" PLUGIN_AUTHOR = "Francis Chin, Sambhav Kothari, Chris Hylen" PLUGIN_DESCRIPTION = """Generate an Extended M3U playlist (.m3u8 file, UTF8 encoded text). Relative pathnames are used where audio files are in the same directory as the playlist, otherwise absolute (full) pathnames are used.""" PLUGIN_VERSION = "1.2.1" PLUGIN_API_VERSIONS = ["2.0"] PLUGIN_LICENSE = "GPL-2.0-or-later" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" import os.path from PyQt5 import QtCore, QtWidgets from picard import log from picard.const import VARIOUS_ARTISTS_ID from picard.util import find_existing_path, encode_filename from picard.ui.itemviews import BaseAction, register_album_action _debug_level = 0 def get_safe_filename(filename): _valid_chars = " .,_-:+&!()" _safe_filename = "".join( c if (c.isalnum() or c in _valid_chars) else "_" for c in filename ).rstrip() return _safe_filename class PlaylistEntry(list): def __init__(self, playlist, index): list.__init__(self) self.playlist = playlist self.index = index def add(self, entry_row): self.append(entry_row + "\n") class Playlist(object): def __init__(self, filename): self.filename = filename self.entries = [] self.headers = [] def add_header(self, header): self.headers.append(header + "\n") def write(self): b_lines = [] for header in self.headers: b_lines.append(header.encode("utf-8")) for entry in self.entries: for row in entry: b_lines.append(row.encode("utf-8")) with open(encode_filename(self.filename), "wb") as f: f.writelines(b_lines) class GeneratePlaylist(BaseAction): NAME = "Generate &Playlist..." def callback(self, objs): # Find common path of all files to default where to save playlist files = [] for album in objs: for track in album.tracks: if track.linked_files: files.append(track.linked_files[0].filename) try: current_directory = os.path.commonpath(files) except ValueError: current_directory = "" # Default playlist filename set as "%albumartist% - %album%.m3u8", # except where "Various Artists" is suppressed if _debug_level > 1: log.debug("{}: VARIOUS_ARTISTS_ID is {}, musicbrainz_albumartistid is {}".format( PLUGIN_NAME, VARIOUS_ARTISTS_ID, objs[0].metadata["musicbrainz_albumartistid"])) if objs[0].metadata["musicbrainz_albumartistid"] != VARIOUS_ARTISTS_ID: default_filename = get_safe_filename( objs[0].metadata["albumartist"] + " - " + objs[0].metadata["album"] + ".m3u8" ) else: default_filename = get_safe_filename( objs[0].metadata["album"] + ".m3u8" ) if _debug_level > 1: log.debug("{}: default playlist filename sanitized to {}".format( PLUGIN_NAME, default_filename)) filename, selected_format = QtWidgets.QFileDialog.getSaveFileName( None, "Save new playlist", os.path.join(current_directory, default_filename), "Playlist (*.m3u8 *.m3u)" ) if filename: playlist = Playlist(filename) playlist.add_header("#EXTM3U") for album in objs: for track in album.tracks: if track.linked_files: entry = PlaylistEntry(playlist, len(playlist.entries)) playlist.entries.append(entry) # M3U EXTINF row track_length_seconds = int(round(track.metadata.length / 1000.0)) # EXTINF format assumed to be fixed as follows: entry.add("#EXTINF:{duration:d},{artist} - {title}".format( duration=track_length_seconds, artist=track.metadata["artist"], title=track.metadata["title"] ) ) # M3U URL row - assumes only one file per track audio_filename = track.linked_files[0].filename if _debug_level > 1: for i, file in enumerate(track.linked_files): log.debug("{}: linked_file {}: {}".format( PLUGIN_NAME, i, str(file))) # If playlist is in same directory as audio files, then use # local (relative) pathname, otherwise use absolute pathname if _debug_level > 1: log.debug("{}: audio_filename: {}, selected dir: {}".format( PLUGIN_NAME, audio_filename, os.path.dirname(filename))) try: audio_filename = os.path.relpath(audio_filename, os.path.dirname(filename)) except ValueError: pass entry.add(str(audio_filename)) playlist.write() register_album_action(GeneratePlaylist()) ================================================ FILE: plugins/post_tagging_actions/__init__.py ================================================ # -*- coding: utf-8 -*- # # Copyright (C) 2024 Giorgio Fontanive (twodoorcoupe) # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. PLUGIN_NAME = "Post Tagging Actions" PLUGIN_AUTHOR = "Giorgio Fontanive" PLUGIN_DESCRIPTION = """ This plugin lets you set up actions that run with a context menu click. An action consists in a command line executed for each album or each track along with a few options to tweak the behaviour. This can be used to run external programs and pass some variables to it. """ PLUGIN_VERSION = "0.1" PLUGIN_API_VERSIONS = ["2.10", "2.11"] PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" PLUGIN_USER_GUIDE_URL = "https://github.com/metabrainz/picard-plugins/tree/2.0/plugins/post_tagging_actions/docs/guide.md" from picard.album import Album from picard.track import Track from picard.ui.options import OptionsPage, register_options_page from picard.ui.itemviews import BaseAction, register_album_action, register_track_action from picard.ui import mainwindow from picard import log, config from picard.const import sys from picard.util import thread from picard.script import parser from .options_post_tagging_actions import Ui_PostTaggingActions from .actions_status import Ui_ActionsStatus from PyQt5 import QtCore, QtWidgets, QtGui from collections import namedtuple from queue import PriorityQueue from threading import Thread, Lock from concurrent import futures from os import path, cpu_count import re import shlex import subprocess # nosec B404 import time WIDGET_UPDATE_INTERVAL = 0.5 # Additional special variables. TRACK_SPECIAL_VARIABLES = { "filepath": lambda file: file, "folderpath": lambda file: path.dirname(file), # pylint: disable=unnecessary-lambda "filename": lambda file: path.splitext(path.basename(file))[0], "filename_ext": lambda file: path.basename(file), # pylint: disable=unnecessary-lambda "directory": lambda file: path.basename(path.dirname(file)) } ALBUM_SPECIAL_VARIABLES = { "get_num_matched_tracks", "get_num_unmatched_files", "get_num_total_files", "get_num_unsaved_files", "is_complete", "is_modified" } # Settings. CANCEL = "pta_cancel" MAX_WORKERS = "pta_max_workers" OPTIONS = ("pta_command", "pta_wait_for_exit", "pta_execute_for_tracks", "pta_refresh_tags") Options = namedtuple("Options", ("variables", *[option[4:] for option in OPTIONS])) Action = namedtuple("Action", ("commands", "album", "options")) PriorityAction = namedtuple("PriorityAction", ("priority", "counter", "action")) action_queue = PriorityQueue() variables_pattern = re.compile(r'%.*?%') class ActionLoader: """Adds actions to the execution queue. Attributes: action_options (list): Stores the actions' information loaded from the options page. action_counter (int): The count of actions that have been added to the queue, used for priority. """ def __init__(self): self.action_options = [] self.action_counter = 0 self.load_actions() def _create_options(self, command, *other_options): """Finds the variables in the command and adds the options to the action options list. """ variables = [parser.normalize_tagname(variable[1:-1]) for variable in variables_pattern.findall(command)] command = variables_pattern.sub('{}', command) options = Options(variables, command, *other_options) self.action_options.append(options) def _create_action(self, priority, commands, album, options): """Adds an action with the given parameters to the execution queue. If the os is not windows, the command is split as suggested by the subprocess module documentation. """ if not sys.IS_WIN: commands = [shlex.split(command) for command in commands] action = Action(commands, album, options) priority_action = PriorityAction(priority, self.action_counter, action) action_queue.put(priority_action) self.action_counter += 1 def _replace_variables(self, variables, item): """Returns a list where each variable is replaced with its value. Item is either an album or a track. For track special variables, it uses the path of the first file of the given item. If the variable is not found anywhere, it remains as in the original text. """ values = [] album = item.album if isinstance(item, Track) else item first_file_path = next(item.iterfiles()).filename for variable in variables: if variable in ALBUM_SPECIAL_VARIABLES: values.append(getattr(album, variable)()) elif variable in TRACK_SPECIAL_VARIABLES: values.append(TRACK_SPECIAL_VARIABLES[variable](first_file_path)) else: values.append(item.metadata.get(variable, f"%{variable}%")) return values def add_actions(self, album, tracks): """Adds one action to the execution queue for each tuple in the action options list. Actions meant to be executed once for each track are considered as a single action. This way, the other options are more consistent. """ for priority, options in enumerate(self.action_options): if options.execute_for_tracks: values_list = [self._replace_variables(options.variables, track) for track in tracks] else: values_list = [self._replace_variables(options.variables, album)] commands = [options.command.format(*values) for values in values_list] self._create_action(priority, commands, album, options) def load_actions(self): """Loads the information from the options and saves it in the action options list. This gets called when the plugin is loaded or when the user saves the options. """ self.action_options = [] option_tuples = zip(*[config.setting[name] for name in OPTIONS]) for option_tuple in option_tuples: command = option_tuple[0] other_options = [eval(option) for option in option_tuple[1:]] # nosec B307 self._create_options(command, *other_options) class ActionRunner: """Runs actions in the execution queue. Attributes: action_thread_pool (ThreadPoolExecutor): Pool used to run processes with the subprocess module. refresh_tags_pool (ThreadPoolExecutor): Pool used to reload tags from files and refresh albums. worker (Thread): Worker thread that picks actions from the execution queue. update_widget (Thread): Thread that updates the number of pending actions in the status bar. """ def __init__(self): self.action_thread_pool = futures.ThreadPoolExecutor(config.setting[MAX_WORKERS]) self.refresh_tags_pool = futures.ThreadPoolExecutor(1) self.worker = Thread(target = self._execute) self.update_widget = Thread(target = self._update_widget) self.worker.start() self.keep_updating = True self.currently_executing = 0 self.currently_executing_lock = Lock() self.status_widget = ActionsStatus() # This is used to register functions that run when the application is being closed. # The stop function makes the background threads return. tagger = QtCore.QCoreApplication.instance() tagger.register_cleanup(self.stop) # This checks whether the tagger has already created the main window. # It should happen only when the plugin is installed through the options menu, # otherwise the plugins are loaded before the main window is created. if hasattr(tagger, "window"): self._create_widget(tagger.window) else: # This is used to register functions that run after the main window has finished loading. mainwindow.register_ui_init(self._create_widget) def _create_widget(self, window): """Adds the pending actions widget to the right of the other icons in the statusbar. """ window.statusBar().insertPermanentWidget(1, self.status_widget) self.update_widget.start() def _update_widget(self): """Updates the number of pending actions in the status bar at regular intervals. """ while self.keep_updating: number_of_actions = action_queue.qsize() + self.currently_executing thread.to_main(self.status_widget.update_actions_count, number_of_actions) time.sleep(WIDGET_UPDATE_INTERVAL) def _refresh_tags(self, future_objects, album): """Reloads tags from the album's files and refreshes the album. First, it makes sure that the action has finished running. This is used for when an external process changes a file's tags. """ futures.wait(future_objects, return_when = futures.ALL_COMPLETED) for file in album.iterfiles(): file.set_pending() file.load(lambda file: None) thread.to_main(album.load, priority = True, refresh = True) def _run_process(self, command): """Runs the process and waits for it to finish. """ process = subprocess.Popen( command, text = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE ) # nosec B603 answer = process.communicate() if answer[0]: log.info("Action output:\n%s", answer[0]) if answer[1]: log.error("Action error:\n%s", answer[1]) def _update_executing_count(self, future_objects): """Decrements the count of executing actions once the given action finishes. """ futures.wait(future_objects, return_when = futures.ALL_COMPLETED) with self.currently_executing_lock: self.currently_executing -= 1 def _execute(self): """Takes actions from the execution queue and runs them. If it finds an action with priority -1, the loop stops. When the loop stops, both ThreadPoolExecutors are shutdown. """ while True: priority_action = action_queue.get() if priority_action.priority == -1: break with self.currently_executing_lock: self.currently_executing += 1 next_action = priority_action.action commands = next_action.commands future_objects = {self.action_thread_pool.submit(self._run_process, command) for command in commands} if next_action.options.wait_for_exit: futures.wait(future_objects, return_when = futures.ALL_COMPLETED) if next_action.options.refresh_tags: self.refresh_tags_pool.submit(self._refresh_tags, future_objects, next_action.album) self.refresh_tags_pool.submit(self._update_executing_count, future_objects) action_queue.task_done() self.action_thread_pool.shutdown(wait = False, cancel_futures = True) self.refresh_tags_pool.shutdown(wait = False, cancel_futures = True) def stop(self): """Makes the worker thread exit its loop. This gets called when Picard is closed. It waits for the processes that are still executing to finish before exiting. """ if not config.setting[CANCEL]: action_queue.join() action_queue.put(PriorityAction(-1, -1, None)) self.keep_updating = False if self.update_widget.is_alive(): self.update_widget.join() self.worker.join() class ExecuteAlbumActions(BaseAction): NAME = "Run actions for highlighted albums" def callback(self, objs): albums = {obj for obj in objs if isinstance(obj, Album)} for album in albums: action_loader.add_actions(album, album.tracks) class ExecuteTrackActions(BaseAction): NAME = "Run actions for highlighted tracks" def callback(self, objs): tracks = {obj for obj in objs if isinstance(obj, Track)} albums = {track.album for track in tracks} for album in albums: album_tracks = tracks.intersection(album.tracks) action_loader.add_actions(album, album_tracks) class PostTaggingActionsOptions(OptionsPage): """Options page found under the "plugins" page. """ NAME = "post_tagging_actions" TITLE = "Post Tagging Actions" PARENT = "plugins" action_options = [config.ListOption("setting", name, []) for name in OPTIONS] options = [ config.BoolOption("setting", CANCEL, True), config.IntOption("setting", MAX_WORKERS, min(32, cpu_count() + 4)), *action_options ] def __init__(self, parent = None): super(PostTaggingActionsOptions, self).__init__(parent) self.ui = Ui_PostTaggingActions() self.ui.setupUi(self) self._reset_ui() header = self.ui.table.horizontalHeader() header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) for column in range(1, header.count()): header.setSectionResizeMode(column, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) self.ui.add_file_path.clicked.connect(self._open_file_dialog) self.ui.add_action.clicked.connect(self._add_action_to_table) self.ui.remove_action.clicked.connect(self._remove_action_from_table) self.ui.up.clicked.connect(self._move_action_up) self.ui.down.clicked.connect(self._move_action_down) self.get_table_columns_values = [ self.ui.action.text, self.ui.wait.isChecked, self.ui.tracks.isChecked, self.ui.refresh.isChecked ] def _open_file_dialog(self): """Adds the selected file's path to the command line text box. """ file = QtWidgets.QFileDialog.getOpenFileName(self)[0] cursor_position = self.ui.action.cursorPosition() current_text = self.ui.action.text() if not sys.IS_WIN: file = shlex.quote(file) new_text = current_text[:cursor_position] + file + current_text[cursor_position:] self.ui.action.setText(new_text) def _reset_ui(self): self.ui.action.setText("") self.ui.wait.setChecked(False) self.ui.refresh.setChecked(False) self.ui.albums.setChecked(True) def _add_action_to_table(self): if not self.ui.action.text(): return row_position = self.ui.table.rowCount() self.ui.table.insertRow(row_position) for column in range(self.ui.table.columnCount()): value = self.get_table_columns_values[column]() value = str(value) widget = QtWidgets.QTableWidgetItem(value) self.ui.table.setItem(row_position, column, widget) self._reset_ui() def _remove_action_from_table(self): current_row = self.ui.table.currentRow() if current_row != -1: self.ui.table.removeRow(current_row) def _move_action_up(self): current_row = self.ui.table.currentRow() new_row = current_row - 1 if current_row > 0: self._swap_table_rows(current_row, new_row) self.ui.table.setCurrentCell(new_row, 0) def _move_action_down(self): current_row = self.ui.table.currentRow() new_row = current_row + 1 if current_row < self.ui.table.rowCount() - 1: self._swap_table_rows(current_row, new_row) self.ui.table.setCurrentCell(new_row, 0) def _swap_table_rows(self, row1, row2): for column in range(self.ui.table.columnCount()): item1 = self.ui.table.takeItem(row1, column) item2 = self.ui.table.takeItem(row2, column) self.ui.table.setItem(row1, column, item2) self.ui.table.setItem(row2, column, item1) def load(self): """Puts the plugin's settings into the actions table. """ settings = zip(*[config.setting[name] for name in OPTIONS]) for row, values in enumerate(settings): self.ui.table.insertRow(row) for column in range(self.ui.table.columnCount()): widget = QtWidgets.QTableWidgetItem(values[column]) self.ui.table.setItem(row, column, widget) self.ui.cancel.setChecked(config.setting[CANCEL]) self.ui.max_workers.setValue(config.setting[MAX_WORKERS]) def save(self): """Saves the actions table items in the settings. """ settings = [] for column in range(self.ui.table.columnCount()): settings.append([]) for row in range(self.ui.table.rowCount()): setting = self.ui.table.item(row, column).text() settings[column].append(setting) config.setting[OPTIONS[column]] = settings[column] config.setting[CANCEL] = self.ui.cancel.isChecked() config.setting[MAX_WORKERS] = self.ui.max_workers.value() action_loader.load_actions() class ActionsStatus(QtWidgets.QWidget, Ui_ActionsStatus): """An icon and a label that displays the number of pending actions. The widget is only visible when there are pending actions. This is placed in the statusbar to the left of the other icons. """ def __init__(self): QtWidgets.QWidget.__init__(self) Ui_ActionsStatus.__init__(self) self.setupUi(self) self.hide() # Creates the icon to the right of the label. size = QtCore.QSize(16, 16) icon = QtGui.QIcon(":/images/16x16/applications-system.png") self.label.setPixmap(icon.pixmap(size)) def update_actions_count(self, count): self.actions_count.setText(f"{count}") self.setVisible(count > 0) action_loader = ActionLoader() action_runner = ActionRunner() register_album_action(ExecuteAlbumActions()) register_track_action(ExecuteTrackActions()) register_options_page(PostTaggingActionsOptions) ================================================ FILE: plugins/post_tagging_actions/actions_status.py ================================================ # -*- coding: utf-8 -*- # Form implementation generated from reading ui file 'plugins/post_tagging_actions/actions_status.ui' # # Created by: PyQt5 UI code generator 5.15.10 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. from PyQt5 import QtCore, QtWidgets class Ui_ActionsStatus(object): def setupUi(self, ActionsStatus): ActionsStatus.setObjectName("ActionsStatus") ActionsStatus.resize(94, 18) self.horizontalLayout = QtWidgets.QHBoxLayout(ActionsStatus) self.horizontalLayout.setContentsMargins(0, 0, 0, 0) self.horizontalLayout.setSpacing(2) self.horizontalLayout.setObjectName("horizontalLayout") self.actions_count = QtWidgets.QLabel(ActionsStatus) self.actions_count.setMinimumSize(QtCore.QSize(35, 0)) self.actions_count.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) self.actions_count.setObjectName("actions_count") self.horizontalLayout.addWidget(self.actions_count) self.label = QtWidgets.QLabel(ActionsStatus) self.label.setText("") self.label.setObjectName("label") self.horizontalLayout.addWidget(self.label) self.retranslateUi(ActionsStatus) QtCore.QMetaObject.connectSlotsByName(ActionsStatus) def retranslateUi(self, ActionsStatus): _translate = QtCore.QCoreApplication.translate ActionsStatus.setWindowTitle(_translate("ActionsStatus", "Form")) self.actions_count.setText(_translate("ActionsStatus", "0")) self.label.setToolTip(_translate("ActionsStatus", "Remaining Actions")) ================================================ FILE: plugins/post_tagging_actions/actions_status.ui ================================================If checked, the album will "refresh" after this action finishes.
")) self.refresh.setText(_translate("PostTaggingActions", " Refresh tags after process finishes")) self.albums.setToolTip(_translate("PostTaggingActions", "Makes the action execute once for each album tagged.")) self.albums.setText(_translate("PostTaggingActions", "Execute for albums")) self.tracks.setToolTip(_translate("PostTaggingActions", "Makes the action run once for each track tagged.")) self.tracks.setText(_translate("PostTaggingActions", "Execute for tracks")) self.add_action.setToolTip(_translate("PostTaggingActions", "Add the action at the bottom of the queue.")) self.add_action.setText(_translate("PostTaggingActions", "Add action")) self.remove_action.setToolTip(_translate("PostTaggingActions", "Remove the selected action.")) self.remove_action.setText(_translate("PostTaggingActions", "Remove action")) self.table.setToolTip(_translate("PostTaggingActions", "Actions at the top of the list run first. Use the buttons on the right to reorder the selected action.")) item = self.table.horizontalHeaderItem(0) item.setText(_translate("PostTaggingActions", "Action")) item = self.table.horizontalHeaderItem(1) item.setText(_translate("PostTaggingActions", " Wait for exit ")) item = self.table.horizontalHeaderItem(2) item.setText(_translate("PostTaggingActions", " Execute for tracks ")) item = self.table.horizontalHeaderItem(3) item.setText(_translate("PostTaggingActions", " Refresh tags ")) self.cancel.setToolTip(_translate("PostTaggingActions", "If not checked, when Picard is closed, it will wait for the actions to finish in the background.
")) self.cancel.setText(_translate("PostTaggingActions", "Cancel actions in the queue when Picard is closed")) self.label_2.setToolTip(_translate("PostTaggingActions", "Sets the number of background threads executing the actions")) self.label_2.setText(_translate("PostTaggingActions", " Maximum number of worker threads (Requires Picard restart)")) self.label.setText(_translate("PostTaggingActions", "Hover over each item to know more, or take a peek at the user guide here.
")) ================================================ FILE: plugins/post_tagging_actions/options_post_tagging_actions.ui ================================================Enter the title and URL for the search engine provider. Titles must be at least two non-space characters long, and must not be the same as the title of an existing provider.
When entering the URL the macro %search% must be included. This will be replaced by the list of search words separated by plus signs when the url is sent to your browser for display.
")) self.label_2.setText(_translate("SearchEngineEditorDialog", "Title:")) self.label_3.setText(_translate("SearchEngineEditorDialog", "URL:")) self.le_title.setToolTip(_translate("SearchEngineEditorDialog", "The title to show in the list for the search engine provider")) self.le_url.setToolTip(_translate("SearchEngineEditorDialog", "The URL to use for the search engine provider")) self.pb_save.setText(_translate("SearchEngineEditorDialog", "Save")) self.pb_cancel.setText(_translate("SearchEngineEditorDialog", "Cancel")) ================================================ FILE: plugins/search_engine_lookup/ui_options_search_engine_editor.ui ================================================The Search Engine Lookup plugin allows you to initiate a search engine lookup in your browser for a cluster from the menu displayed when right-clicking the cluster. These settings allow you select which search engine to use for the lookup, as well as any additional search terms.
")) self.gb_search_engine.setTitle(_translate("SearchEngineLookupOptionsPage", "Search Engine Providers")) self.pb_add.setText(_translate("SearchEngineLookupOptionsPage", "Add")) self.pb_edit.setText(_translate("SearchEngineLookupOptionsPage", "Edit")) self.pb_delete.setText(_translate("SearchEngineLookupOptionsPage", "Delete")) self.pb_test.setText(_translate("SearchEngineLookupOptionsPage", "Test")) self.groupBox.setTitle(_translate("SearchEngineLookupOptionsPage", "Additional Search Words")) self.label.setText(_translate("SearchEngineLookupOptionsPage", "By default, the search parameters used are the artist name and album name if they are available in the metadata. You have the option of specifying additional words to be added to the search parameters (e.g.: album).
Additional words are entered below and should be separated by spaces.
")) ================================================ FILE: plugins/search_engine_lookup/ui_options_search_engine_lookup.ui ================================================Performer [acoustic guitar, bass, dobro, electric guitar and tambourine]: Graham Gouldman Performer [acoustic guitar, electric guitar, grand piano and synthesizer]: Lol Creme Performer [electric guitar, moog and slide guitar]: Eric Stewartbecomes:
Performer [acoustic guitar]: Graham Gouldman; Lol Creme Performer [bass]: Graham Gouldman Performer [dobro]: Graham Gouldman Performer [electric guitar]: Eric Stewart; Graham Gouldman; Lol Creme Performer [grand piano]: Lol Creme Performer [moog]: Eric Stewart Performer [slide guitar]: Eric Stewart Performer [synthesizer]: Lol Creme Performer [tambourine]: Graham GouldmanUpdate: This version now sorts the performer tags in order to maintain a consistent value and avoid tags appearing to change even though the base data is equivalent. ''' PLUGIN_VERSION = '1.0' PLUGIN_API_VERSIONS = ["2.0"] PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html" import re from picard import log from picard.metadata import register_track_metadata_processor standardise_performers_split = re.compile(r", | and ").split def standardise_performers(album, metadata, *args): for key, values in list(metadata.rawitems()): if not key.startswith('performer:') \ and not key.startswith('~performersort:'): continue mainkey, subkey = key.split(':', 1) if not subkey: continue instruments = standardise_performers_split(subkey) if len(instruments) == 1: continue log.debug("%s: Splitting Performer [%s] into separate performers", PLUGIN_NAME, subkey, ) prefixes = [] words = instruments[0].split() for word in words[:]: if not word in ['guest', 'solo', 'additional', 'minor']: break prefixes.append(word) words.remove(word) instruments[0] = " ".join(words) prefix = " ".join(prefixes) + " " if prefixes else "" for instrument in instruments: newkey = '%s:%s%s' % (mainkey, prefix, instrument) for value in values: metadata.add_unique(newkey, value) del metadata[key] # Sort performer metdata to avoid changes in processing sequence creating false changes in metadata for key, values in list(metadata.rawitems()): if key.startswith('performer:') or key.startswith('~performersort:'): metadata[key] = sorted(values) from picard.plugin import PluginPriority register_track_metadata_processor(standardise_performers, priority=PluginPriority.HIGH) ================================================ FILE: plugins/submit_folksonomy_tags/README.md ================================================ # Submit Folksonomy Tags - Picard Plugin A plugin that lets the user submit tags from their tracks' tags - defaults to `genre` and `mood` - to their respective MusicBrainz pages' folksonomy tags via MusicBrainz Picard. Useful for music geeks who are meticulous with their genre tagging. **This plugin requires that you log into MusicBrainz via Picard.** The option to do so is in _Options > Options > General_. To use, right click on a track or release, then go to _Plugins > Submit **x** tags to MusicBrainz_ - there are multiple options depending on if you want to submit tags to the recording, release, release group or release artist associated with the track/s or album/s you've right-clicked. The tags will be applied from the track tags you have configured. Uses code from rswift's "Submit ISRC" plugin (specifically, the handling of the network response) ## Features It does what it says on the tin: submits any tags you have in the genre tags of whichever files you drop into Picard to the respective pages. Right now the following entities are supported: - recordings - releases - release groups - artists (by release) The plugin can also replace certain tags if your tags don't match up with MusicBrainz's standard tags, notably with their allowed genre list (e.g. if you use "synthpop" and not "synth-pop", or you use the full name "electronic dance music" and not the abbreviated "edm"). ## Limitations Right now, this plugin only submits tags. No tags are _retrieved_ for comparison yet, meaning I've opted to implement two modes based on how the MusicBrainz API works: maintain the tags that are already saved or overwrite _all_ of your tags. For anyone using the MusicBrainz API, choosing to keep your tags is basically sending the "upvote" attribute with every user tag, and choosing to overwrite doesn't do that, which MusicBrainz will respond by clearing old tags. See the [tags section of the MusicBrainz API for more details.](https://musicbrainz.org/doc/MusicBrainz_API#tags) Submitting for releases, release artists and release groups will also trigger an alert if your tags are not consistently the same across all tracks in an album. This is to prevent spamming of tags, purposeful or accidental, and is based on the standard already set for years by digital music sites and CD ripper utilities where an album would have the same genres tagged across all tracks. ================================================ FILE: plugins/submit_folksonomy_tags/__init__.py ================================================ # -*- coding: utf-8 -*- # # Copyright (C) 2023 Flaky # Copyright (C) 2023 Bob Swift (rdswift) # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. PLUGIN_NAME = "Submit Folksonomy Tags" PLUGIN_AUTHOR = "Flaky" PLUGIN_DESCRIPTION = """ A plugin allowing submission of specific tags on tracks you own (defaults to genre and mood) as folksonomy tags on MusicBrainz. Supports submitting to recording, release, release group and release artist entities. A MusicBrainz login is required to use this plugin. Log in first by going to the General options. Then, to use, right click on a track or release then go to Plugins and depending on what you want to submit, choose the option you want. Uses code from rdswift's "Submit ISRC" plugin (specifically, the handling of the network response) """ PLUGIN_VERSION = '0.3' PLUGIN_API_VERSIONS = ['2.2', '2.9'] PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt" from picard import config, log, PICARD_VERSION from picard.album import Album, Track from picard.ui.itemviews import (BaseAction, register_album_action, register_track_action ) from picard.ui.options import OptionsPage, register_options_page from picard.version import Version from picard.webservice.api_helpers import MBAPIHelper, _wrap_xml_metadata from .ui_config import TagSubmitPluginOptionsUI import re import functools from xml.sax.saxutils import escape from PyQt5 import QtCore from PyQt5.QtWidgets import QMessageBox NEW_MBAPIHelper = (PICARD_VERSION >= Version(2, 9, 0, 'beta', 2)) # List of Qt network error codes. # From "Submit ISRC" plugin - credit to rdswift. q_error_codes = { 0: 'No error', 1: "The remote server refused the connection (the server is not accepting requests).", 2: "The remote server closed the connection prematurely, before the entire reply was received and processed.", 3: "The remote host name was not found (invalid hostname).", 4: "The connection to the remote server timed out.", 5: "The operation was canceled via calls to abort() or close() before it was finished.", 6: "The SSL/TLS handshake failed and the encrypted channel could not be established. The sslErrors() signal should have been emitted.", 7: "The connection was broken due to disconnection from the network, however the system has initiated roaming to another access point. The request should be resubmitted and will be processed as soon as the connection is re-established.", 8: "The connection was broken due to disconnection from the network or failure to start the network.", 9: "The background request is not currently allowed due to platform policy.", 10: "While following redirects, the maximum limit was reached.", 11: "While following redirects, the network access API detected a redirect from a encrypted protocol (https) to an unencrypted one (http).", 99: "An unknown network-related error was detected.", 101: "The connection to the proxy server was refused (the proxy server is not accepting requests).", 102: "The proxy server closed the connection prematurely, before the entire reply was received and processed.", 103: "The proxy host name was not found (invalid proxy hostname).", 104: "The connection to the proxy timed out or the proxy did not reply in time to the request sent.", 105: "The proxy requires authentication in order to honour the request but did not accept any credentials offered (if any).", 199: "An unknown proxy-related error was detected.", 201: "The access to the remote content was denied (similar to HTTP error 403).", 202: "The operation requested on the remote content is not permitted.", 203: "The remote content was not found at the server (similar to HTTP error 404).", 204: "The remote server requires authentication to serve the content but the credentials provided were not accepted (if any).", 205: "The request needed to be sent again, but this failed for example because the upload data could not be read a second time.", 206: "The request could not be completed due to a conflict with the current state of the resource.", 207: "The requested resource is no longer available at the server.", 299: "An unknown error related to the remote content was detected.", 301: "The Network Access API cannot honor the request because the protocol is not known.", 302: "The requested operation is invalid for this protocol.", 399: "A breakdown in protocol was detected (parsing error, invalid or unexpected responses, etc.).", 401: "The server encountered an unexpected condition which prevented it from fulfilling the request.", 402: "The server does not support the functionality required to fulfill the request.", 403: "The server is unable to handle the request at this time.", 499: "An unknown error related to the server response was detected.", } # Some internal settings. # Don't change these unless you know what you're doing. # You can change the tags you want to submit in the settings. client_params = { "client": f"picard_plugin_{PLUGIN_NAME.replace(' ', '_')}-v{PLUGIN_VERSION}" } default_tags_to_submit = ['genre', 'mood'] # The options as saved in Picard.ini config.BoolOption("setting", 'tag_submit_plugin_destructive', False) config.BoolOption("setting", 'tag_submit_plugin_destructive_alert_acknowledged', False) config.BoolOption("setting", 'tag_submit_plugin_aliases_enabled', False) config.ListOption("setting", 'tag_submit_plugin_alias_list', []) config.ListOption("setting", 'tag_submit_plugin_tags_to_submit', default_tags_to_submit) def tag_submit_handler(document, reply, error, tagger): """ The function handling the network response from MusicBrainz or QtNetwork, showing a message box if an error had occurred. Uses the network response handler code from rdswift's "Submit ISRC" plugin. """ if error: # Error handling from rdswift's Submit ISRC plugin xml_text = str(document, 'UTF-8') if isinstance(document, (bytes, bytearray, QtCore.QByteArray)) else str(document) # Build error text message from returned xml payload err_text = '' matches = re.findall(r'
An error has occurred submitting the tags to MusicBrainz.
{err_text}
") error.exec_() else: tagger.window.set_statusbar_message( "Successfully submitted tags to MusicBrainz." ) def process_tag_aliases(tag_input): """ Retrieves a string as input, and searches the tag alias tuple list for a match. """ matched_tag_index = next( (tag for tag, tag_tuple in enumerate(config.setting['tag_submit_plugin_alias_list']) if tag_tuple[0] == tag_input.lower()), None ) if matched_tag_index is not None: resolved_tag = config.setting['tag_submit_plugin_alias_list'][matched_tag_index][1] return resolved_tag else: return tag_input def process_objs_to_track_list(objs): """ Creates a track list out of Album/Track objects """ track_list = [] for item in objs: if isinstance(item, Track): track_list.append(item) elif isinstance(item, Album): if len(item.tracks) > 0: for track in item.tracks: track_list.append(track) return track_list # TODO handle artist def handle_submit_process(tagger, track_list, target_tag): """ Does some pre-processing before submitting tags. Handles tag deduplication and halting when inconsistent tagging is detected (i.e. the user is trying to submit tags to a release with the submitted track tags not being consistent.) """ dict_key = "" tags_to_search = config.setting['tag_submit_plugin_tags_to_submit'] # Variable to enable when inconsistent tagging is detected, which can be problematic for anything other than recordings. alert_inconsistent = True inconsistent_detected = False # Variable to enable alert if multiple MBIDs are associated, must be toggled. alert_multiple_mbids = False # TODO when Windows Picard updates with Python 3.10, use case/switch. if target_tag == "musicbrainz_recordingid": dict_key = "recording" alert_inconsistent = False elif target_tag == "musicbrainz_albumid": dict_key = "release" elif target_tag == "musicbrainz_releasegroupid": dict_key = "release-group" elif target_tag == "musicbrainz_albumartistid" or target_tag == "musicbrainz_artistid": dict_key = "artist" data = {dict_key: {}} last_tags = {"mbid": ""} banned_mbids = { # Any artist entities that can be applied to multiple artists go here. # SPAs generally fit the bill here. "f731ccc4-e22a-43af-a747-64213329e088", # artist: [anonymous] "33cf029c-63b0-41a0-9855-be2a3665fb3b", # artist: [data] "314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc", # artist: [dialogue] "eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61", # artist: [no artist] "9be7f096-97ec-4615-8957-8d40b5dcbc41", # artist: [traditional] "125ec42a-7229-4250-afc5-e057484327fe", # artist: [unknown] "89ad4ac3-39f7-470e-963a-56509c546377", # artist: Various Artists "66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49", # artist: [Disney] "a0ef7e1d-44ff-4039-9435-7d5fefdeecc9", # artist: [theatre] "80a8851f-444c-4539-892b-ad2a49292aa9", # artist: [language instruction] "", # blank } for track in track_list: if track.files: for file in track.files: mbid_list = file.metadata.getall(target_tag) if len(mbid_list) > 1: alert_multiple_mbids = True for mbid in mbid_list: if mbid not in banned_mbids: processed_tags = [] for tag in tags_to_search: if file.metadata[tag]: if tag not in last_tags: pass else: # Flip the switch when current tag didn't match last tag on current mbid, on an entity that needs this checked. if (last_tags[tag] != file.metadata[tag]) and (last_tags["mbid"] == file.metadata[target_tag]) and alert_inconsistent: inconsistent_detected = True # in any case, process the tags in case the user intends to go with it. processed_tags.extend([tag.strip().lower() for tag in re.split(";|/|,", file.metadata[tag])]) last_tags[tag] = file.metadata[tag] last_tags["mbid"] = file.metadata[target_tag] # If a track has multiple files associated to it, there may be duplicate tags. processed_tags = list(set(processed_tags)) if processed_tags and mbid in data[dict_key]: data[dict_key][mbid].extend(processed_tags) else: data[dict_key][mbid] = processed_tags else: log.info(f"Not submitting MBID {track.metadata[target_tag]} as it was found on 'do not submit' MBID set.") # Send an alert when, at the end of it all, inconsistent tagging was detected. if inconsistent_detected or alert_multiple_mbids: warning = QMessageBox() warning.setStandardButtons(QMessageBox.Ok|QMessageBox.Cancel) warning.setDefaultButton(QMessageBox.Cancel) warning.setIcon(QMessageBox.Warning) if inconsistent_detected and alert_multiple_mbids: warning.setText("""WARNING: INCONSISTENT TAGGING AND SUBMISSION TO MULTIPLE MBIDS DETECTED.
You are trying to apply different tags to multiple MusicBrainz entities.
This isn't a use case this plugin supports whatsoever due to the potential for wrong tags to be unintentionally assigned, but detects and warns just in case.
If this was intentional, click OK. Otherwise, click Cancel.
""") elif inconsistent_detected: warning.setText("""WARNING: INCONSISTENT TAGGING DETECTED.
You are trying to apply multiple tags to one entity, which benefits more from having the same tags across all tracks when submitting tags via this plugin.
If you intended to have tracks in a release to have different submitted tags, it's better to cancel this attempt and choose the recording option. If you didn't, you should apply the same tag across all tracks.
If this was intentional, click OK. Otherwise, click Cancel.
""") elif alert_multiple_mbids: warning.setText("""MULTIPLE MBIDS DETECTED.
You are trying to apply a tag to multiple MusicBrainz entities.
This isn't a use case this plugin supports whatsoever due to the potential for wrong tags to be unintentionally assigned, but detects and warns just in case.
If this was intentional, click OK. Otherwise, click Cancel.
""") result = warning.exec_() if result == QMessageBox.Ok: upload_tags_to_mbz(data, tagger) else: tagger.window.set_statusbar_message( "Tag submission halted by user request." ) else: upload_tags_to_mbz(data, tagger) def upload_tags_to_mbz(data, tagger): """ Generates the XML from the data retrieved, and then uploads it to MusicBrainz. """ helper = MBAPIHelper(tagger.webservice) empty_data = { "WARNING: BY SELECTING TO OVERWRITE ALL TAGS, THIS MEANS ALL TAGS.
By enabling this option, you acknowledge that you may lose the tags already saved online from the tracks you process via this plugin. This alert will only be displayed once before you save.
If you do not want this behaviour, select the maintain option.
") warning.exec_() config.setting['tag_submit_plugin_destructive_alert_acknowledged'] = True def load(self): # Destructive option if config.setting['tag_submit_plugin_destructive']: self.ui.overwrite_radio_button.setChecked(True) else: self.ui.keep_radio_button.setChecked(True) self.ui.tags_to_save_textbox.setText( '; '.join(config.setting['tag_submit_plugin_tags_to_submit']) ) # Aliases enabled option self.ui.tag_alias_groupbox.setChecked( config.setting['tag_submit_plugin_aliases_enabled'] ) # Alias list if 'tag_submit_plugin_alias_list' in config.setting: log.info("Alias list exists! Let's populate the table.") for alias_tuple in config.setting['tag_submit_plugin_alias_list']: self.ui.add_row(alias_tuple[0], alias_tuple[1]) self.ui.tag_alias_table.resizeColumnsToContents() config.setting['tag_submit_plugin_destructive_alert_acknowledged'] = self.destructive_acknowledgement def save(self): config.setting['tag_submit_plugin_destructive'] = self.ui.overwrite_radio_button.isChecked() config.setting['tag_submit_plugin_aliases_enabled'] = self.ui.tag_alias_groupbox.isChecked() tag_textbox_text = self.ui.tags_to_save_textbox.text() if tag_textbox_text: config.setting['tag_submit_plugin_tags_to_submit'] = [ tag.strip() for tag in tag_textbox_text.split(';') ] else: config.setting['tag_submit_plugin_tags_to_submit'] = default_tags_to_submit if config.setting['tag_submit_plugin_aliases_enabled']: new_alias_list = self.ui.rows_to_tuple_list() log.info(new_alias_list) config.setting['tag_submit_plugin_alias_list'] = new_alias_list class SubmitTrackTagsMenuAction(BaseAction): NAME = 'Submit tags to MusicBrainz (recording)' def callback(self, objs): handle_submit_process( objs[0].tagger, process_objs_to_track_list(objs), "musicbrainz_recordingid" ) class SubmitReleaseTagsMenuAction(BaseAction): NAME = 'Submit tags to MusicBrainz (release)' def callback(self, objs): handle_submit_process( objs[0].tagger, process_objs_to_track_list(objs), "musicbrainz_albumid" ) class SubmitRGTagsMenuAction(BaseAction): NAME = 'Submit tags to MusicBrainz (release group)' def callback(self, objs): handle_submit_process( objs[0].tagger, process_objs_to_track_list(objs), "musicbrainz_releasegroupid" ) class SubmitRATagsMenuAction(BaseAction): NAME = 'Submit tags to MusicBrainz (release artist)' def callback(self, objs): handle_submit_process( objs[0].tagger, process_objs_to_track_list(objs), "musicbrainz_albumartistid" ) register_options_page(TagSubmitPlugin_OptionsPage) register_album_action(SubmitTrackTagsMenuAction()) register_track_action(SubmitTrackTagsMenuAction()) register_album_action(SubmitReleaseTagsMenuAction()) register_track_action(SubmitReleaseTagsMenuAction()) register_album_action(SubmitRGTagsMenuAction()) register_track_action(SubmitRGTagsMenuAction()) register_album_action(SubmitRATagsMenuAction()) register_track_action(SubmitRATagsMenuAction()) ================================================ FILE: plugins/submit_folksonomy_tags/ui_config.py ================================================ from PyQt5.QtCore import ( QSize, Qt ) from PyQt5.QtWidgets import ( QGridLayout, QGroupBox, QHBoxLayout, QLabel, QPushButton, QRadioButton, QSizePolicy, QTableWidget, QTableWidgetItem, QVBoxLayout, QAbstractItemView, QLineEdit ) class TagSubmitPluginOptionsUI(): def __init__(self, page): self.main_container = QVBoxLayout() sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) # Group box: tag saving self.tag_save_groupbox = QGroupBox() sizePolicy.setHeightForWidth(self.tag_save_groupbox.sizePolicy().hasHeightForWidth()) self.tag_save_groupbox.setSizePolicy(sizePolicy) self.tag_save_groupbox_layout = QGridLayout(self.tag_save_groupbox) # Label: tag saving description self.tag_save_description = QLabel(self.tag_save_groupbox) sizePolicy.setHeightForWidth(self.tag_save_description.sizePolicy().hasHeightForWidth()) self.tag_save_description.setSizePolicy(sizePolicy) self.tag_save_description.setMinimumSize(QSize(0, 56)) self.tag_save_description.setWordWrap(True) self.tag_save_groupbox_layout.addWidget(self.tag_save_description, 0, 0, 1, 1) self.tag_save_groupbox.setTitle("Tag Saving") self.tag_save_description.setText(u"There are two modes to tag saving via this plugin right now: keep all existing saved tags (only adding on tags) or overwrite them. If you are not in a position where replacing your saved tags online is a good idea, it is recommended to keep this option unchanged.
") self.main_container.addWidget(self.tag_save_groupbox) self.tag_save_groupbox_layout.addWidget(self.tag_save_description, 0, 0, 1, 1) self.tags_to_save_groupbox = QGroupBox() sizePolicy.setHeightForWidth(self.tags_to_save_groupbox.sizePolicy().hasHeightForWidth()) self.tags_to_save_groupbox.setSizePolicy(sizePolicy) self.tags_to_save_groupbox_layout = QGridLayout(self.tags_to_save_groupbox) self.tags_to_save_groupbox.setTitle("Tags to Submit") self.tags_to_save_description = QLabel(self.tags_to_save_groupbox) sizePolicy.setHeightForWidth(self.tags_to_save_description.sizePolicy().hasHeightForWidth()) self.tags_to_save_textbox = QLineEdit() self.tags_to_save_description.setText("List the tags that you want to submit to MusicBrainz via the plugin in the text box below. Separate each tag with a semi-colon. (e.g. genre; mood)
") self.tags_to_save_groupbox_layout.addWidget(self.tags_to_save_description) self.tags_to_save_textbox.setPlaceholderText("Tags you want to submit (separated by semicolons - e.g. genre; mood)") self.tags_to_save_groupbox_layout.addWidget(self.tags_to_save_textbox) self.main_container.addWidget(self.tags_to_save_groupbox) # Radio buttons for tag saving options (on the plugin as "destructive") self.keep_radio_button = QRadioButton(self.tag_save_groupbox) self.keep_radio_button.setText("Keep existing online saved tags") self.tag_save_groupbox_layout.addWidget(self.keep_radio_button, 1, 0, 1, 1) self.overwrite_radio_button = QRadioButton(self.tag_save_groupbox) self.overwrite_radio_button.setText("Overwrite all online saved tags") self.tag_save_groupbox_layout.addWidget(self.overwrite_radio_button, 2, 0, 1, 1) # Group box: tag aliases self.tag_alias_groupbox = QGroupBox() sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) sizePolicy1.setHorizontalStretch(0) sizePolicy1.setVerticalStretch(0) sizePolicy1.setHeightForWidth(self.tag_alias_groupbox.sizePolicy().hasHeightForWidth()) self.tag_alias_groupbox.setSizePolicy(sizePolicy1) self.tag_alias_groupbox.setCheckable(True) self.tag_alias_groupbox.setChecked(False) self.tag_alias_groupbox_layout = QVBoxLayout(self.tag_alias_groupbox) self.tag_alias_groupbox.setTitle("Tag Aliases") self.tag_alias_description = QLabel() self.tag_alias_description.setText("There may be cases where you prefer one tag on your files to be saved as another on MusicBrainz (e.g. if your genre tags don't align with MusicBrainz's standard genre tags). In such cases, the plugin can substitute your tags with whichever tags you want when submitting.
Anything listed here is case-insensitive, as MusicBrainz will process them in lowercase anyway.
") self.tag_alias_description.setMinimumSize(QSize(0, 42)) self.tag_alias_description.setWordWrap(True) self.tag_alias_groupbox_layout.addWidget(self.tag_alias_description) # Tag alias table self.tag_alias_table = QTableWidget() self.tag_alias_table.setColumnCount(2) __find_column = QTableWidgetItem() __find_column.setText("Find...") self.tag_alias_table.setHorizontalHeaderItem(0, __find_column) __replace_column = QTableWidgetItem() __replace_column.setText("Replace...") self.tag_alias_table.setHorizontalHeaderItem(1, __replace_column) self.tag_alias_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.tag_alias_groupbox_layout.addWidget(self.tag_alias_table) # Tag alias buttons self.table_button_layout = QHBoxLayout() self.add_row_button = QPushButton() self.delete_row_button = QPushButton() self.add_row_button.setText("Add tag alias") self.add_row_button.clicked.connect(self.add_row) self.delete_row_button.setText("Delete selected tag aliases") self.delete_row_button.clicked.connect(self.delete_rows) self.table_button_layout.addWidget(self.add_row_button) self.table_button_layout.addWidget(self.delete_row_button) self.tag_alias_groupbox_layout.addLayout(self.table_button_layout) self.main_container.addWidget(self.tag_alias_groupbox) page.setLayout(self.main_container) def add_row(self, find_entry="", replace_entry=""): """ Adds a row to the table. Accepts input. """ row_pos = self.tag_alias_table.rowCount() self.tag_alias_table.insertRow(row_pos) find_tableitem = QTableWidgetItem(find_entry) replace_tableitem = QTableWidgetItem(replace_entry) self.tag_alias_table.setItem(row_pos, 0, find_tableitem) self.tag_alias_table.setItem(row_pos, 1, replace_tableitem) def delete_rows(self): """ Self-explanatory - removes the selected rows. """ for row in self.tag_alias_table.selectionModel().selectedRows(): self.tag_alias_table.removeRow(row.row()) def rows_to_tuple_list(self): """ Converts filled in rows to a list of tuples for the alias list setting. """ tuple_list = [] row_count = self.tag_alias_table.rowCount() for row in range(row_count): find_tableitem = self.tag_alias_table.item(row, 0).text() replace_tableitem = self.tag_alias_table.item(row, 1).text() if find_tableitem and replace_tableitem: tuple_list.append((find_tableitem, replace_tableitem)) return tuple_list ================================================ FILE: plugins/submit_isrc/README.md ================================================ # Submit ISRC ## Overview This plugin adds a right click option on an album to submit the ISRCs to the MusicBrainz server specified in the Option settings. To use this function, you must first match your files to the appropriate tracks for a release. Once this is done, but before you save your files if you have Picard set to overwrite the `isrc` tag in your files, right-click the release and select "Submit ISRCs" in the "Plugins" section. For each file that has a single valid ISRC in its metadata, the ISRC will be added to the recording on the release if it does not already exist. Once all tracks for the release have been processed, the missing ISRCs will be submitted to MusicBrainz. If a file's metadata contains multiple ISRCs, such as if the file has already been tagged, then no ISRCs will be submitted for that file. If one of the files contains an invalid ISRC, or if the same ISRC appears in the metadata for two or more files, then a notice will be displayed and the submission process will be aborted. When ISRCs have been submitted, a notice will be displayed showing whether or not the submission was successful. --- ================================================ FILE: plugins/submit_isrc/__init__.py ================================================ # -*- coding: utf-8 -*- # # Copyright (C) 2020-2021, 2023 Bob Swift (rdswift) # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. PLUGIN_NAME = 'Submit ISRC' PLUGIN_AUTHOR = 'Bob Swift' PLUGIN_DESCRIPTION = '''Adds a right click option on an album to submit the ISRCs to the MusicBrainz server specified in the Options.
To use this function, you must first match your files to the appropriate tracks for a release. Once this is done, but before you save your files if you have Picard set to overwrite the 'isrc' tag in your files, right-click the release and select "Submit ISRCs" in the "Plugins" section. For each file that has a single valid ISRC in its metadata, the ISRC will be added to the recording on the release if it does not already exist. Once all tracks for the release have been processed, the missing ISRCs will be submitted to MusicBrainz.
If a file's metadata contains multiple ISRCs, such as if the file has already been tagged, then no ISRCs will be submitted for that file.
If one of the files contains an invalid ISRC, or if the same ISRC appears in the metadata for two or more files, then a notice will be displayed and the submission process will be aborted.
When ISRCs have been submitted, a notice will be displayed showing whether or not the submission was successful.
''' PLUGIN_VERSION = '1.1' PLUGIN_API_VERSIONS = ['2.0', '2.1', '2.2', '2.3', '2.6', '2.9'] PLUGIN_LICENSE = "GPL-2.0" PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt" import re from picard import log, PICARD_VERSION from picard.ui.itemviews import BaseAction, register_album_action from picard.version import Version from picard.webservice.api_helpers import MBAPIHelper, _wrap_xml_metadata from PyQt5 import QtCore, QtWidgets RE_VALIDATE_ISRC = re.compile(r'^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$') NEW_MBAPIHelper = (PICARD_VERSION >= Version(2, 9, 0, 'beta', 2)) XML_HEADER = '