diff --git a/beets/library/models.py b/beets/library/models.py index aee055134..f710d2b5d 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -40,6 +40,7 @@ class LibModel(dbcore.Model["Library"]): # Config key that specifies how an instance should be formatted. _format_config_key: str path: bytes + length: float @cached_classproperty def _types(cls) -> dict[str, types.Type]: @@ -616,6 +617,11 @@ class Album(LibModel): for item in self.items(): item.try_sync(write, move) + @cached_property + def length(self) -> float: # type: ignore[override] # still writable since we override __setattr__ + """Return the total length of all items in this album in seconds.""" + return sum(item.length for item in self.items()) + class Item(LibModel): """Represent a song or track.""" diff --git a/beets/random.py b/beets/random.py deleted file mode 100644 index f3318054c..000000000 --- a/beets/random.py +++ /dev/null @@ -1,112 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Philippe Mongeau. -# -# 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. - -"""Get a random song or album from the library.""" - -import random -from itertools import groupby -from operator import attrgetter - - -def _length(obj, album): - """Get the duration of an item or album.""" - if album: - return sum(i.length for i in obj.items()) - else: - return obj.length - - -def _equal_chance_permutation(objs, field="albumartist", random_gen=None): - """Generate (lazily) a permutation of the objects where every group - with equal values for `field` have an equal chance of appearing in - any given position. - """ - rand = random_gen or random - - # Group the objects by artist so we can sample from them. - key = attrgetter(field) - objs.sort(key=key) - objs_by_artists = {} - for artist, v in groupby(objs, key): - objs_by_artists[artist] = list(v) - - # While we still have artists with music to choose from, pick one - # randomly and pick a track from that artist. - while objs_by_artists: - # Choose an artist and an object for that artist, removing - # this choice from the pool. - artist = rand.choice(list(objs_by_artists.keys())) - objs_from_artist = objs_by_artists[artist] - i = rand.randint(0, len(objs_from_artist) - 1) - yield objs_from_artist.pop(i) - - # Remove the artist if we've used up all of its objects. - if not objs_from_artist: - del objs_by_artists[artist] - - -def _take(iter, num): - """Return a list containing the first `num` values in `iter` (or - fewer, if the iterable ends early). - """ - out = [] - for val in iter: - out.append(val) - num -= 1 - if num <= 0: - break - return out - - -def _take_time(iter, secs, album): - """Return a list containing the first values in `iter`, which should - be Item or Album objects, that add up to the given amount of time in - seconds. - """ - out = [] - total_time = 0.0 - for obj in iter: - length = _length(obj, album) - if total_time + length <= secs: - out.append(obj) - total_time += length - return out - - -def random_objs( - objs, album, number=1, time=None, equal_chance=False, random_gen=None -): - """Get a random subset of the provided `objs`. - - If `number` is provided, produce that many matches. Otherwise, if - `time` is provided, instead select a list whose total time is close - to that number of minutes. If `equal_chance` is true, give each - artist an equal chance of being included so that artists with more - songs are not represented disproportionately. - """ - rand = random_gen or random - - # Permute the objects either in a straightforward way or an - # artist-balanced way. - if equal_chance: - perm = _equal_chance_permutation(objs) - else: - perm = objs - rand.shuffle(perm) # N.B. This shuffles the original list. - - # Select objects by time our count. - if time: - return _take_time(perm, time * 60, album) - else: - return _take(perm, number) diff --git a/beetsplug/random.py b/beetsplug/random.py index c791af414..aa65dd5d5 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -1,5 +1,6 @@ # This file is part of beets. # Copyright 2016, Philippe Mongeau. +# Copyright 2025, Sebastian Mohr. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -12,26 +13,36 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Get a random song or album from the library.""" +from __future__ import annotations + +import random +from itertools import groupby, islice +from operator import methodcaller +from typing import TYPE_CHECKING from beets.plugins import BeetsPlugin -from beets.random import random_objs from beets.ui import Subcommand, print_ +if TYPE_CHECKING: + import optparse + from collections.abc import Iterable -def random_func(lib, opts, args): + from beets.library import LibModel, Library + + +def random_func(lib: Library, opts: optparse.Values, args: list[str]): """Select some random items or albums and print the results.""" # Fetch all the objects matching the query into a list. - if opts.album: - objs = list(lib.albums(args)) - else: - objs = list(lib.items(args)) + objs = lib.albums(args) if opts.album else lib.items(args) # Print a random subset. - objs = random_objs( - objs, opts.album, opts.number, opts.time, opts.equal_chance - ) - for obj in objs: + for obj in random_objs( + objs=objs, + equal_chance_field=opts.field, + number=opts.number, + time_minutes=opts.time, + equal_chance=opts.equal_chance, + ): print_(format(obj)) @@ -48,7 +59,7 @@ random_cmd.parser.add_option( "-e", "--equal-chance", action="store_true", - help="each artist has the same chance", + help="each field has the same chance", ) random_cmd.parser.add_option( "-t", @@ -57,6 +68,13 @@ random_cmd.parser.add_option( type="float", help="total length in minutes of objects to choose", ) +random_cmd.parser.add_option( + "--field", + action="store", + type="string", + default="albumartist", + help="field to use for equal chance sampling (default: albumartist)", +) random_cmd.parser.add_all_common_options() random_cmd.func = random_func @@ -64,3 +82,77 @@ random_cmd.func = random_func class Random(BeetsPlugin): def commands(self): return [random_cmd] + + +def _equal_chance_permutation( + objs: Iterable[LibModel], field: str +) -> Iterable[LibModel]: + """Generate (lazily) a permutation of the objects where every group + with equal values for `field` have an equal chance of appearing in + any given position. + """ + # Group the objects by field so we can sample from them. + get_attr = methodcaller("get", field) + + groups = {} + for k, values in groupby(sorted(objs, key=get_attr), key=get_attr): + if k is not None: + vals = list(values) + # shuffle in category + random.shuffle(vals) + groups[str(k)] = vals + + while groups: + group = random.choice(list(groups.keys())) + yield groups[group].pop() + if not groups[group]: + del groups[group] + + +def _take_time( + iter: Iterable[LibModel], + secs: float, +) -> Iterable[LibModel]: + """Return a list containing the first values in `iter`, which should + be Item or Album objects, that add up to the given amount of time in + seconds. + """ + total_time = 0.0 + for obj in iter: + length = obj.length + if total_time + length <= secs: + yield obj + total_time += length + + +def random_objs( + objs: Iterable[LibModel], + equal_chance_field: str, + number: int = 1, + time_minutes: float | None = None, + equal_chance: bool = False, +) -> Iterable[LibModel]: + """Get a random subset of items, optionally constrained by time or count. + + Args: + - objs: The sequence of objects to choose from. + - number: The number of objects to select. + - time_minutes: If specified, the total length of selected objects + should not exceed this many minutes. + - equal_chance: If True, each field has the same chance of being + selected, regardless of how many tracks they have. + - random_gen: An optional random generator to use for shuffling. + """ + # Permute the objects either in a straightforward way or an + # field-balanced way. + if equal_chance: + perm = _equal_chance_permutation(objs, equal_chance_field) + else: + perm = list(objs) + random.shuffle(perm) + + # Select objects by time our count. + if time_minutes: + return _take_time(perm, time_minutes * 60) + else: + return islice(perm, number) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6126f70aa..aec3f143f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,6 +52,8 @@ New features: untouched the files without. - :doc:`plugins/fish`: Filenames are now completed in more places, like after ``beet import``. +- :doc:`plugins/random`: Added ``--field`` option to specify which field to use + for equal-chance sampling (default: ``albumartist``). Bug fixes: @@ -146,6 +148,7 @@ Other changes: - Finally removed gmusic plugin and all related code/docs as the Google Play Music service was shut down in 2020. - Updated color documentation with ``bright_*`` and ``bg_bright_*`` entries. +- Moved `beets/random.py` into `beetsplug/random.py` to cleanup core module. 2.5.1 (October 14, 2025) ------------------------ diff --git a/docs/plugins/random.rst b/docs/plugins/random.rst index ca227c4b8..b3f15da4c 100644 --- a/docs/plugins/random.rst +++ b/docs/plugins/random.rst @@ -8,24 +8,115 @@ listen to. First, enable the plugin named ``random`` (see :ref:`using-plugins`). You'll then be able to use the ``beet random`` command: -:: +.. code-block:: shell - $ beet random - Aesop Rock - None Shall Pass - The Harbor Is Yours + beet random + >> Aesop Rock - None Shall Pass - The Harbor Is Yours -The command has several options that resemble those for the ``beet list`` -command (see :doc:`/reference/cli`). To choose an album instead of a single -track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and -to use a custom format for printing, use ``-f FORMAT``. +Usage +----- -If the ``-e`` option is passed, the random choice will be even among artists -(the albumartist field). This makes sure that your anthology of Bob Dylan won't -make you listen to Bob Dylan 50% of the time. +The basic command selects and displays a single random track. Several options +allow you to customize the selection: -The ``-n NUMBER`` option controls the number of objects that are selected and -printed (default 1). To select 5 tracks from your library, type ``beet random --n5``. +.. code-block:: shell -As an alternative, you can use ``-t MINUTES`` to choose a set of music with a -given play time. To select tracks that total one hour, for example, type ``beet -random -t60``. + Usage: beet random [options] + + Options: + -h, --help show this help message and exit + -n NUMBER, --number=NUMBER + number of objects to choose + -e, --equal-chance each field has the same chance + -t TIME, --time=TIME total length in minutes of objects to choose + --field=FIELD field to use for equal chance sampling (default: + albumartist) + -a, --album match albums instead of tracks + -p PATH, --path=PATH print paths for matched items or albums + -f FORMAT, --format=FORMAT + print with custom format + +Detailed Options +---------------- + +``-n, --number=NUMBER`` + Select multiple items at once. The default is 1. + +``-e, --equal-chance`` + Give each distinct value of a field an equal chance of being selected. This + prevents artists with many albums/tracks from dominating the selection. + + **Implementation note:** When this option is used, the plugin: + + 1. Groups items by the specified field + 2. Shuffles items within each group + 3. Randomly selects groups, then items from those groups + 4. Continues until all groups are exhausted + + Items without the specified field (``--field``) value are excluded from the + selection. + +``--field=FIELD`` + Specify which field to use for equal chance sampling. Default is + ``albumartist``. + +``-t, --time=TIME`` + Select items whose total duration (in minutes) is approximately equal to + TIME. The plugin will continue adding items until the total exceeds the + requested time. + +``-a, --album`` + Operate on albums instead of tracks. + +``-p, --path`` + Output filesystem paths instead of formatted metadata. + +``-f, --format=FORMAT`` + Use a custom format string for output. See :doc:`/reference/query` for + format syntax. + +Examples +-------- + +Select multiple items: + +.. code-block:: shell + + # Select 5 random tracks + beet random -n 5 + + # Select 3 random albums + beet random -a -n 3 + +Control selection fairness: + +.. code-block:: shell + + # Ensure equal chance per artist (default field: albumartist) + beet random -e + + # Ensure equal chance per genre + beet random -e --field genre + +Select by total playtime: + +.. code-block:: shell + + # Select tracks totaling 60 minutes (1 hour) + beet random -t 60 + + # Select albums totaling 120 minutes (2 hours) + beet random -a -t 120 + +Custom output formats: + +.. code-block:: shell + + # Print only artist and title + beet random -f '$artist - $title' + + # Print file paths + beet random -p + + # Print album paths + beet random -a -p diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index cb21edf47..975bb8ffa 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -15,27 +15,43 @@ """Test the beets.random utilities associated with the random plugin.""" import math -import unittest -from random import Random +import random import pytest -from beets import random from beets.test.helper import TestHelper +from beetsplug.random import _equal_chance_permutation, random_objs -class RandomTest(TestHelper, unittest.TestCase): - def setUp(self): - self.lib = None +@pytest.fixture(scope="class") +def helper(): + helper = TestHelper() + helper.setup_beets() + + yield helper + + helper.teardown_beets() + + +@pytest.fixture(scope="module", autouse=True) +def seed_random(): + random.seed(12345) + + +class TestEqualChancePermutation: + """Test the _equal_chance_permutation function.""" + + @pytest.fixture(autouse=True) + def setup(self, helper): + """Set up the test environment with items.""" + self.lib = helper.lib self.artist1 = "Artist 1" self.artist2 = "Artist 2" - self.item1 = self.create_item(artist=self.artist1) - self.item2 = self.create_item(artist=self.artist2) + self.item1 = helper.create_item(artist=self.artist1) + self.item2 = helper.create_item(artist=self.artist2) self.items = [self.item1, self.item2] for _ in range(8): - self.items.append(self.create_item(artist=self.artist2)) - self.random_gen = Random() - self.random_gen.seed(12345) + self.items.append(helper.create_item(artist=self.artist2)) def _stats(self, data): mean = sum(data) / len(data) @@ -61,9 +77,7 @@ class RandomTest(TestHelper, unittest.TestCase): positions = [] for _ in range(500): shuffled = list( - random._equal_chance_permutation( - self.items, field=field, random_gen=self.random_gen - ) + _equal_chance_permutation(self.items, field=field) ) positions.append(shuffled.index(self.item1)) # Print a histogram (useful for debugging). @@ -77,3 +91,94 @@ class RandomTest(TestHelper, unittest.TestCase): assert 0 == pytest.approx(median1, abs=1) assert len(self.items) // 2 == pytest.approx(median2, abs=1) assert stdev2 > stdev1 + + @pytest.mark.parametrize( + "input_items, field, expected", + [ + ([], "artist", []), + ([{"artist": "Artist 1"}], "artist", [{"artist": "Artist 1"}]), + # Missing field should not raise an error, but return empty + ([{"artist": "Artist 1"}], "nonexistent", []), + # Multiple items with the same field value + ( + [{"artist": "Artist 1"}, {"artist": "Artist 1"}], + "artist", + [{"artist": "Artist 1"}, {"artist": "Artist 1"}], + ), + ], + ) + def test_equal_permutation_items( + self, input_items, field, expected, helper + ): + """Test _equal_chance_permutation with empty input.""" + result = list( + _equal_chance_permutation( + [helper.create_item(**i) for i in input_items], field + ) + ) + + for item in expected: + for key, value in item.items(): + assert any(getattr(r, key) == value for r in result) + assert len(result) == len(expected) + + +class TestRandomObjs: + """Test the random_objs function.""" + + @pytest.fixture(autouse=True) + def setup(self, helper): + """Set up the test environment with items.""" + self.lib = helper.lib + self.artist1 = "Artist 1" + self.artist2 = "Artist 2" + self.items = [ + helper.create_item(artist=self.artist1, length=180), # 3 minutes + helper.create_item(artist=self.artist2, length=240), # 4 minutes + helper.create_item(artist=self.artist2, length=300), # 5 minutes + ] + + def test_random_selection_by_count(self): + """Test selecting a specific number of items.""" + selected = list(random_objs(self.items, "artist", number=2)) + assert len(selected) == 2 + assert all(item in self.items for item in selected) + + def test_random_selection_by_time(self): + """Test selecting items constrained by total time (minutes).""" + selected = list( + random_objs(self.items, "artist", time_minutes=6) + ) # 6 minutes + total_time = ( + sum(item.length for item in selected) / 60 + ) # Convert to minutes + assert total_time <= 6 + + def test_equal_chance_permutation(self, helper): + """Test equal chance permutation ensures balanced artist selection.""" + # Add more items to make the test meaningful + for _ in range(5): + self.items.append( + helper.create_item(artist=self.artist1, length=180) + ) + + selected = list( + random_objs(self.items, "artist", number=10, equal_chance=True) + ) + artist_counts = {} + for item in selected: + artist_counts[item.artist] = artist_counts.get(item.artist, 0) + 1 + + # Ensure both artists are represented (not strictly equal due to randomness) + assert len(artist_counts) >= 2 + + def test_empty_input_list(self): + """Test behavior with an empty input list.""" + selected = list(random_objs([], "artist", number=1)) + assert len(selected) == 0 + + def test_no_constraints_returns_all(self): + """Test that no constraints return all items in random order.""" + selected = list(random_objs(self.items, "artist", number=3)) + assert len(selected) == len(self.items) + assert set(selected) == set(self.items)