Determining whether condition applies to multiple files

Hello, I’m trying to figure out whether it is possible to do the following with scripting:

  1. Determine whether a release consists of the same media type or not. For example, whether all of its discs are CDs, or if one is something else, such as a live DVD, bonus 7" or something. I would like to file them into a directory tagged, for example, “Album 2CD”, and if mixed “Album something else”.

  2. Whether all the files in a digital release are of the same bitrate and samplerate. I have releases where an album might contain a few bonus tracks at a better quality. I’d like to file albums with a consistent bitrate away like “album 16-44.1” with mixed releases filed away like “album mixed”. I wouldn’t want a single release to be sorted into multiple directories based on differing qualities.

Thanks!

Just a bump to see if anyone knows.

Is comparison between files in a complete release possible?

What I want to do: if a release’s files are all of the same bit depth + samplerate, i.e all files match 24bit/96kHz, include this detail its directory name.

I totally had missed your initial thread :frowning:

But for the question: Out of the box Picard does not offer a way to check all files for specific data. A plugin could do this, maybe by providing a scripting function to get the common value for a tag.

Overall having a way to get a common value for a tag for all files of a release sounds very useful. Actually I could imagine to have scripting functions to aggregate the values of a tag across files on a single release in various ways:

  • get a value if it is the same across all files
  • get the value that most files have
  • get an average (for numerical tags)
  • get a list of all values

Would also be useful for e.g. genre. Currently I don’t 5hink such a plugin exists.

3 Likes

I started something that might enable this sort of functionality at:

https://github.com/rdswift/picard-plugins/tree/2.0_RDS_Plugins/plugins/album_level_tags

I haven’t done much testing on it yet, and it definitely doesn’t aggregate the values for you, but it’s there if someone wants to use it as a starting point to develop something with a bit more functionality.

3 Likes

I gave this a go with a slightly different approach:

https://raw.githubusercontent.com/phw/picard-plugins/releasetag_aggregations/plugins/releasetag_aggregations/releasetag_aggregations.py

Note: The functions names starting with $album_ were previously called $releasetag_. I changed this with an update to version 0.4 and have updated this post accordingly.

This plugin provides a couple of functions that can be used to get aggregated values from all files on the release. Note: Due to technical limitations these functions work only when used in a naming script.

E.g. one can use the following to get a common media (note: variable names must be passed by name, e.g. media, not with value, e.g. %media%):

$album_all(media)

If all files have the same value for media (e.g. “CD”) that value will be returned, otherwise it will be empty. You can use this to just check whether all files have the same type or not:

$if($album_all(media),all same media type,different media type)

For handling numeric values e.g. for including the bitrate in a folder name there are $album_min(), $album_max() and $album_avg(). E.g. you could do a bitrate range:

$album_min(_bitrate)-$album_max(_bitrate)

Or to have it use only a single value if all files are identical combine it with $album_all:

$if2($album_all(_bitrate),$album_min(_bitrate)-$album_max(_bitrate))

Another option is to use an average bitrate:

$album_avg(_bitrate)

For something like genres you might want to use the most common value. There is $album_mode() for this:

$album_mode(genre)

If you have 4 files tagged with a genre of “rock” and 3 with genre “metal” this will use “rock”. Now often there are multiple genres in a file (that means a genre tag is a multi-value tag). Let’s assume we have a release with individual tags tagged with the following genres:

  1. rock; metal
  2. rock; blues
  3. blues
  4. rock; metal
  5. blues; alternative
  6. blues; jazz

Using $album_mode(genre) would pick “rock; metal” as the genre, because this is the most common combination. But maybe you want the most common single value? There is $album_multi_mode() for this:

$album_multi_mode(genre)

This looks at all the values individually and hence will give “blues” (which is used 4 times, followed by rock 3 times).

There are also multi tag variations of min, max and average ($album_multi_min(), $album_multi_max(), $album_multi_avg()).

To install the script the easiest is to download the file releasetag_aggregations.py from https://raw.githubusercontent.com/phw/picard-plugins/releasetag_aggregations/plugins/releasetag_aggregations/releasetag_aggregations.py and install it from inside Picard via Options > Plugins > Install plugin…

3 Likes

Excellent, I always wanted to write such a plugin myself, but did not find the time do it.
Thank you very much!

I guess it would require major code changes in Picard itself to make this also work for tagging scripts?
One specific use case for this I have in mind is replacing the album artist for classical and audiobook releases using the most common writer or performer credits which are only available at the recording level (in most cases).

It would require code changes, but actually I don’t think it would be much. The script parser supports providing a file when evaluating a script. Originally a file only ever was available when saving / renaming. But as Picard supports using the file tags since Picard 2.5 we could also provide this to the parser. In addition I think it would kind of make sense to provide more context in general, e.g. we could always provide access to the album, track or cluster, independent of whether a file is available or not. This can be a bit tricky for developers of scripting functions to consider all cases, but would provide great flexibility. E.g. in the functions here I already handle both the case that a file is matched to a proper release on the right or only added to a cluster on the left.

3 Likes

Thanks for this. I don’t seem to be able to get it working right now, though.

I’ve installed the plugin, and it’s activated, but I see an error telling me that $releasetag_all is an unknown function. I’ll give it a closer look this weekend. I think my filenaming script could do with an overhaul.

Ideally what I’d like to end up with, with a little psuedocode:

if _bits_per_sample >= 24:
  write "24-bit" to dir name
  if all tracks have the same _sample_rate:
    write eg. "44.1kHz" to dir name
  else:
    write "mixed" to dir name
else:
  forget about it

Thanks again for looking into this. The mix, max stuff looks very handy.

Sorry for the confusion. After some discussion on the review of my plugin I changed the naming to $album_*, e.g. $album_all(_bits_per_sample ). I’ll update my post above accordingly to avoid future confusion.

For your use case something like this should do:

$if($album_all(_bits_per_sample ),
$album_all(_bits_per_sample)-$if($album_all(_sample_rate),$album_all(_sample_rate) Hz,mixed)
)

If you want to use kHz maybe instead of $album_all(_sample_rate) Hz use $div($album_all(_sample_rate),1000) kHz. Not sure right now how this ends up with rounding, but just try it.

UPDATE: Thinking about it the double calls to $album_all can be avoided. If this worked once we know for sure the value of the current file matches, so we could do:

$if($album_all(_bits_per_sample ),
%_bits_per_sample%-$if($album_all(_sample_rate),$div(%_sample_rate%,1000) kHz,mixed)
)
1 Like

Ah, thanks!

Perhaps this is inefficient or something, but it does the trick

$if($gte(%_bits_per_sample%,24),%_bits_per_sample%-bit $if($album_all(_sample_rate),$div(%_sample_rate%,1000)$if($ne($mod(%_sample_rate%,1000),0),.$left($mod(%_sample_rate%,1000),1))kHz,$div($album_max(_sample_rate),1000)$if($ne($mod($album_max(_sample_rate),1000),0),.$left($mod($album_max(_sample_rate),1000),1))kHz),)

My main issue was Picard splitting files between two directories, should some property differ. The above mess should dump the files into a directory with the album’s max samlplerate.

1 Like

I guess we should have some number formatting function :smiley: Or at least extend $num for a precision parameter to specify the number of digits after the comma.

2 Likes

Is this plugin still around in some form? I did a clean Golden Gate install, and have installed the most recent Picard v3 release candidate. Perhaps it’s in the registry and I’m just not seeing it.

I’ve made a lot of use of it over the years, so thanks for creating it.

@certainly The plugin was only ever a quick thing I just made for this thread here as a proof-of-concept, and it only lived on that Github gist. Looks like it got accidentally deleted. I don’t have a copy, and actually I didn’t even remember it.

It sounds like a nice idea, though. If I find the time I can take a look at this again and try to create a plugin for Picard 3 (and this time store it somewhere proper). Right now I don’t even remember how I implemented the functionality, I really wonder how I did this.

If someone following this thread has a copy somewhere, please share :slight_smile:

Update: nevermind, I found a local copy. It’s a nice plugin really :slight_smile: Give me some time to port it to Picard 3.

3 Likes

I’m the deleted user that started this thread :slight_smile:

I’ve got a backup of the file:

# -*- coding: utf-8 -*-
#
# Copyright (C) 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 = 'Release tag aggregation functions'
PLUGIN_AUTHOR = 'Philipp Wolfer'
PLUGIN_DESCRIPTION = ('Add functions to aggregate tags on a release:'
                      '<ul>'
                      '<li>$album_all(name)</li>'
                      '<li>‎$album_avg(name, precision=2)</li>'
                      '<li>‎$album_max(name, precision=2)</li>'
                      '<li>‎$album_min(name, precision=2)</li>'
                      '<li>‎$album_mode(name)</li>'
                      '<li>‎$album_distinct(name, separator=; )</li>'
                      '<li>‎$album_multi_avg(name, precision=2)</li>'
                      '<li>‎$album_multi_max(name, precision=2)</li>'
                      '<li>‎$album_multi_min(name, precision=2)</li>'
                      '<li>‎$album_multi_mode(name)</li>'
                      '<li>‎$album_multi_distinct(name, separator=; )</li>'
                      '</ul>'
                      '<b>The functions work only in file naming scripts and '
                      'the files should either be part of a release or cluster!</b>')
PLUGIN_VERSION = "0.4"
PLUGIN_API_VERSIONS = ["2.5", "2.6"]
PLUGIN_LICENSE = "GPL-2.0-or-later"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"

from functools import partial

from picard.cluster import Cluster
from picard.metadata import MULTI_VALUED_JOINER
from picard.script import script_function
from picard.script.parser import normalize_tagname


def get_parent_release(file):
    if file.parent:
        if isinstance(file.parent, Cluster):
            return file.parent
        elif hasattr(file.parent, 'album') and file.parent.album:
            return file.parent.album
    return None


def iter_release_values(name, file):
    parent = get_parent_release(file)
    if parent:
        for file in parent.iterfiles(save=True):
            yield file.metadata.get(name, '')
    else:
        yield file.metadata.get(name, '')


def iter_release_values_multi(name, file):
    parent = get_parent_release(file)
    if parent:
        for file in parent.iterfiles(save=True):
            yield from file.metadata.getall(name)
    else:
        yield from file.metadata.getall(name)


def try_iter_numeric(values, skip_non_numeric=False):
    for value in values:
        # First try treating the value as an integer, if this fails try float
        try:
            yield int(value)
        except (TypeError, ValueError):
            try:
                yield float(value)
            except (TypeError, ValueError):
                if not skip_non_numeric:
                    yield value


def aggregate_release_tags(parser, name, aggregate_func, multi=False):
    name = normalize_tagname(name)
    file = parser.file
    if file:
        iter_func = iter_release_values_multi if multi else iter_release_values
        return aggregate_func(iter_func(name, file))
    else:  # Nothing to aggregate, return base value
        return parser.context.get(name, '')


def format_number(value, precision=2):
    try:
        precision = int(precision)
    except (TypeError, ValueError):
        precision = 0
    if isinstance(value, int):
        return str(value)
    elif isinstance(value, float):
        fmt = '{:.' + str(precision) + 'f}'
        return fmt.format(value)
    elif not value:
        return ''
    else:
        return str(value)


def mode(values):
    """Returns the mode, the value with the most occurrences, from values."""
    l = list(values)
    if not l:
        return ''
    return max(set(l), key=l.count)


def average(values, precision=2):
    """Returns the arithmetic average of all numeric elements in values.

    Non-numeric elements are ignored.
    """
    numbers = list(try_iter_numeric(values, skip_non_numeric=True))
    if not numbers:
        return ''
    return format_number(sum(numbers) / len(numbers), precision=precision)


def natsort_min(values, precision=2):
    """Returns the smallest value from values, treats numeric strings as numbers."""
    return format_number(max(try_iter_numeric(values)), precision=precision)


def natsort_max(values, precision=2):
    """Returns the largest value from values, treats numeric strings as numbers."""
    return format_number(max(try_iter_numeric(values)), precision=precision)


def distinct(values, separator=MULTI_VALUED_JOINER):
    return separator.join(set(values))


@script_function(documentation="""`$album_all(name)`

Returns the value of the tag name if all files on the album have the same
value for this tag. Otherwise returns empty.
**Only works in File Naming scripts.**""")
def func_album_all(parser, name):
    def aggregate_func(values):
        common_value = ''
        for value in values:
            if not common_value:
                common_value = value
            if common_value != value:
                return ''
        return common_value

    return aggregate_release_tags(parser, name, aggregate_func)


@script_function(documentation="""`$album_mode(name)`

Returns the value with the most occurrences over all the files on the release for the given tag.
**Only works in File Naming scripts.**""")
def func_album_mode(parser, name):
    return aggregate_release_tags(parser, name, mode)


@script_function(documentation="""`$album_multi_mode(name)`

Returns the value with the most occurrences over all the files on the release for the given tag.
Similar to $album_mode(), but considers each value of multi-value tags separately.
**Only works in File Naming scripts.**""")
def func_album_multi_mode(parser, name):
    return aggregate_release_tags(parser, name, mode, multi=True)


@script_function(documentation="""`$album_min(name, precision=2)`

Returns the minimum value of all the files on the release for the given tag.
**Only works in File Naming scripts.**""")
def func_album_min(parser, name, precision="2"):
    aggregate_func = partial(natsort_min, precision=precision)
    return aggregate_release_tags(parser, name, aggregate_func)


@script_function(documentation="""`$album_multi_min(name, precision=2)`

Returns the minimum value of all the files on the release for the given tag.
Similar to $album_min(), but considers each value of multi-value tags separately.
**Only works in File Naming scripts.**""")
def releasetag_multi_min(parser, name, precision="2"):
    aggregate_func = partial(natsort_min, precision=precision)
    return aggregate_release_tags(parser, name, aggregate_func, multi=True)


@script_function(documentation="""`$album_max(name, precision=2)`

Returns the maximum value of all the files on the release for the given tag.
**Only works in File Naming scripts.**""")
def func_album_max(parser, name, precision="2"):
    aggregate_func = partial(natsort_max, precision=precision)
    return aggregate_release_tags(parser, name, aggregate_func)


@script_function(documentation="""`$album_multi_max(name, precision=2)`

Returns the maximum value of all the files on the release for the given tag.
Similar to $album_max(), but considers each value of multi-value tags separately.
**Only works in File Naming scripts.**""")
def releasetag_multi_max(parser, name, precision="2"):
    aggregate_func = partial(natsort_max, precision=precision)
    return aggregate_release_tags(parser, name, aggregate_func, multi=True)


@script_function(documentation="""`$album_avg(name, precision=2)`

Returns the arithmetical average value of all the files on the release for the given tag.
Non-numeric values are ignored. Returns empty if no numeric value is present.
**Only works in File Naming scripts.**""")
def func_album_avg(parser, name, precision="2"):
    aggregate_func = partial(average, precision=precision)
    return aggregate_release_tags(parser, name, aggregate_func)


@script_function(documentation="""`$album_multi_avg(name, precision=2)`

Returns the arithmetical average value of all the files on the release for the given tag.
Non-numeric values are ignored. Returns empty if no numeric value is present.
Similar to $album_avg(), but considers each value of multi-value tags separately.
**Only works in File Naming scripts.**""")
def func_album_multi_avg(parser, name, precision="2"):
    aggregate_func = partial(average, precision=precision)
    return aggregate_release_tags(parser, name, aggregate_func, multi=True)


@script_function(documentation="""`$album_distinct(name, separator=; )`

Returns a multi-value tag with all distinct values of tag across all the files on the release.
**Only works in File Naming scripts.**""")
def func_album_distinct(parser, name, separator=MULTI_VALUED_JOINER):
    aggregate_func = partial(distinct, separator=separator)
    return aggregate_release_tags(parser, name, aggregate_func)


@script_function(documentation="""`$album_multi_distinct(name, separator=; )`

Returns a multi-value tag with all distinct values of tag across all the files on the release.
Similar to $album_distinct(), but considers each value of multi-value tags separately.
**Only works in File Naming scripts.**""")
def func_album_multi_distinct(parser, name, separator=MULTI_VALUED_JOINER):
    aggregate_func = partial(distinct, separator=separator)
    return aggregate_release_tags(parser, name, aggregate_func, multi=True)

3 Likes