From 1165758e1e2d8873b18e0e80e37e42ea6fd77aba Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 9 Aug 2025 13:40:57 +0200 Subject: [PATCH 01/10] Moved functions from random.py into random plugin. Removed random.py --- beets/random.py | 112 -------------------------------------------- beetsplug/random.py | 98 +++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 113 deletions(-) delete mode 100644 beets/random.py 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..330dc78e4 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -14,8 +14,11 @@ """Get a random song or album from the library.""" +import random +from itertools import groupby +from operator import attrgetter + from beets.plugins import BeetsPlugin -from beets.random import random_objs from beets.ui import Subcommand, print_ @@ -64,3 +67,96 @@ random_cmd.func = random_func class Random(BeetsPlugin): def commands(self): return [random_cmd] + + +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) From 34e0de3e1f469da3817a0e124020774c653eec6a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sat, 9 Aug 2025 14:26:15 +0200 Subject: [PATCH 02/10] Added typehints and some more tests. --- beetsplug/random.py | 59 +++++++++++++++++++----------- docs/changelog.rst | 1 + test/plugins/test_random.py | 73 ++++++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 23 deletions(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index 330dc78e4..fcd72c83e 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -14,10 +14,14 @@ """Get a random song or album from the library.""" -import random -from itertools import groupby -from operator import attrgetter +from __future__ import annotations +import random +from itertools import groupby, islice +from operator import attrgetter +from typing import Iterable, Sequence, TypeVar + +from beets.library import Album, Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ @@ -69,15 +73,19 @@ class Random(BeetsPlugin): return [random_cmd] -def _length(obj, album): +def _length(obj: Item | Album) -> float: """Get the duration of an item or album.""" - if album: + if isinstance(obj, Album): return sum(i.length for i in obj.items()) else: return obj.length -def _equal_chance_permutation(objs, field="albumartist", random_gen=None): +def _equal_chance_permutation( + objs: Sequence[Item | Album], + field: str = "albumartist", + random_gen: random.Random | None = None, +) -> Iterable[Item | Album]: """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. @@ -86,7 +94,7 @@ def _equal_chance_permutation(objs, field="albumartist", random_gen=None): # Group the objects by artist so we can sample from them. key = attrgetter(field) - objs.sort(key=key) + objs = sorted(objs, key=key) objs_by_artists = {} for artist, v in groupby(objs, key): objs_by_artists[artist] = list(v) @@ -106,28 +114,31 @@ def _equal_chance_permutation(objs, field="albumartist", random_gen=None): del objs_by_artists[artist] -def _take(iter, num): +T = TypeVar("T") + + +def _take( + iter: Iterable[T], + num: int, +) -> list[T]: """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 + return list(islice(iter, num)) -def _take_time(iter, secs, album): +def _take_time( + iter: Iterable[Item | Album], + secs: float, +) -> list[Item | 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 = [] + out: list[Item | Album] = [] total_time = 0.0 for obj in iter: - length = _length(obj, album) + length = _length(obj) if total_time + length <= secs: out.append(obj) total_time += length @@ -135,7 +146,11 @@ def _take_time(iter, secs, album): def random_objs( - objs, album, number=1, time=None, equal_chance=False, random_gen=None + objs: Sequence[Item | Album], + number=1, + time: float | None = None, + equal_chance: bool = False, + random_gen: random.Random | None = None, ): """Get a random subset of the provided `objs`. @@ -152,11 +167,11 @@ def random_objs( if equal_chance: perm = _equal_chance_permutation(objs) else: - perm = objs - rand.shuffle(perm) # N.B. This shuffles the original list. + perm = list(objs) + rand.shuffle(perm) # Select objects by time our count. if time: - return _take_time(perm, time * 60, album) + return _take_time(perm, time * 60) else: return _take(perm, number) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6126f70aa..6ccdd6060 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -146,6 +146,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/test/plugins/test_random.py b/test/plugins/test_random.py index cb21edf47..5b71cd126 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -20,8 +20,8 @@ from random import Random import pytest -from beets import random from beets.test.helper import TestHelper +from beetsplug import random class RandomTest(TestHelper, unittest.TestCase): @@ -77,3 +77,74 @@ 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 + + def test_equal_permutation_empty_input(self): + """Test _equal_chance_permutation with empty input.""" + result = list(random._equal_chance_permutation([], "artist")) + assert result == [] + + def test_equal_permutation_single_item(self): + """Test _equal_chance_permutation with single item.""" + result = list(random._equal_chance_permutation([self.item1], "artist")) + assert result == [self.item1] + + def test_equal_permutation_single_artist(self): + """Test _equal_chance_permutation with items from one artist.""" + items = [self.create_item(artist=self.artist1) for _ in range(5)] + result = list(random._equal_chance_permutation(items, "artist")) + assert set(result) == set(items) + assert len(result) == len(items) + + def test_random_objs_count(self): + """Test random_objs with count-based selection.""" + result = random.random_objs( + self.items, number=3, random_gen=self.random_gen + ) + assert len(result) == 3 + assert all(item in self.items for item in result) + + def test_random_objs_time(self): + """Test random_objs with time-based selection.""" + # Total length is 30 + 60 + 8*45 = 450 seconds + # Requesting 120 seconds should return 2-3 items + result = random.random_objs( + self.items, + time=2, + random_gen=self.random_gen, # 2 minutes = 120 sec + ) + total_time = sum(item.length for item in result) + assert total_time <= 120 + # Check we got at least some items + assert len(result) > 0 + + def test_random_objs_equal_chance(self): + """Test random_objs with equal_chance=True.""" + + # With equal_chance, artist1 should appear more often in results + def experiment(): + """Run the random_objs function multiple times and collect results.""" + results = [] + for _ in range(5000): + result = random.random_objs( + [self.item1, self.item2], + number=1, + equal_chance=True, + random_gen=self.random_gen, + ) + results.append(result[0].artist) + + # Return ratio + return results.count(self.artist1), results.count(self.artist2) + + count_artist1, count_artist2 = experiment() + assert 1 - count_artist1 / count_artist2 < 0.1 # 10% deviation + + def test_random_objs_empty_input(self): + """Test random_objs with empty input.""" + result = random.random_objs([], number=3) + assert result == [] + + def test_random_objs_zero_number(self): + """Test random_objs with number=0.""" + result = random.random_objs(self.items, number=0) + assert result == [] From bcb22e6c8533880b0fd37177c07cbd61d4abb81a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Sun, 10 Aug 2025 22:20:28 +0200 Subject: [PATCH 03/10] Overall refactor of random plugin. Added length property to albums. --- beets/library/models.py | 5 ++ beetsplug/random.py | 145 +++++++++++++----------------- test/plugins/test_random.py | 174 +++++++++++++++++++++--------------- 3 files changed, 171 insertions(+), 153 deletions(-) diff --git a/beets/library/models.py b/beets/library/models.py index aee055134..e259f4e96 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -616,6 +616,11 @@ class Album(LibModel): for item in self.items(): item.try_sync(write, move) + @property + def length(self) -> float: + """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/beetsplug/random.py b/beetsplug/random.py index fcd72c83e..933049d96 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -1,17 +1,3 @@ -# 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.""" from __future__ import annotations @@ -19,26 +5,31 @@ from __future__ import annotations import random from itertools import groupby, islice from operator import attrgetter -from typing import Iterable, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, Iterable, Sequence, TypeVar -from beets.library import Album, Item from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ +if TYPE_CHECKING: + import optparse -def random_func(lib, opts, args): + from beets.library import LibModel, Library + + T = TypeVar("T", bound=LibModel) + + +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=list(objs), + number=opts.number, + time_minutes=opts.time, + equal_chance=opts.equal_chance, + ): print_(format(obj)) @@ -73,105 +64,93 @@ class Random(BeetsPlugin): return [random_cmd] -def _length(obj: Item | Album) -> float: - """Get the duration of an item or album.""" - if isinstance(obj, Album): - return sum(i.length for i in obj.items()) - else: - return obj.length +NOT_FOUND_SENTINEL = object() def _equal_chance_permutation( - objs: Sequence[Item | Album], + objs: Sequence[T], field: str = "albumartist", random_gen: random.Random | None = None, -) -> Iterable[Item | Album]: +) -> Iterable[T]: """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 + rand: random.Random = random_gen or random.Random() # Group the objects by artist so we can sample from them. key = attrgetter(field) - objs = sorted(objs, 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) + def get_attr(obj: T) -> Any: + try: + return key(obj) + except AttributeError: + return NOT_FOUND_SENTINEL - # Remove the artist if we've used up all of its objects. - if not objs_from_artist: - del objs_by_artists[artist] + groups: dict[Any, list[T]] = { + NOT_FOUND_SENTINEL: [], + } + for k, values in groupby(objs, key=get_attr): + groups[k] = list(values) + # shuffle in category + rand.shuffle(groups[k]) - -T = TypeVar("T") - - -def _take( - iter: Iterable[T], - num: int, -) -> list[T]: - """Return a list containing the first `num` values in `iter` (or - fewer, if the iterable ends early). - """ - return list(islice(iter, num)) + # Remove items without the field value. + del groups[NOT_FOUND_SENTINEL] + while groups: + group = rand.choice(list(groups.keys())) + yield groups[group].pop() + if not groups[group]: + del groups[group] def _take_time( - iter: Iterable[Item | Album], + iter: Iterable[T], secs: float, -) -> list[Item | Album]: +) -> Iterable[T]: """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: list[Item | Album] = [] total_time = 0.0 for obj in iter: - length = _length(obj) + length = obj.length if total_time + length <= secs: - out.append(obj) + yield obj total_time += length - return out def random_objs( - objs: Sequence[Item | Album], - number=1, - time: float | None = None, + objs: Sequence[T], + number: int = 1, + time_minutes: float | None = None, equal_chance: bool = False, random_gen: random.Random | None = None, -): - """Get a random subset of the provided `objs`. +) -> Iterable[T]: + """Get a random subset of items, optionally constrained by time or count. - 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. + 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 artist has the same chance of being + selected, regardless of how many tracks they have. + - random_gen: An optional random generator to use for shuffling. """ - rand = random_gen or random + rand: random.Random = random_gen or random.Random() # Permute the objects either in a straightforward way or an # artist-balanced way. + perm: Iterable[T] if equal_chance: - perm = _equal_chance_permutation(objs) + perm = _equal_chance_permutation(objs, random_gen=rand) else: perm = list(objs) rand.shuffle(perm) # Select objects by time our count. - if time: - return _take_time(perm, time * 60) + if time_minutes: + return _take_time(perm, time_minutes * 60) else: - return _take(perm, number) + return islice(perm, number) diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index 5b71cd126..a7e57280a 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -15,7 +15,6 @@ """Test the beets.random utilities associated with the random plugin.""" import math -import unittest from random import Random import pytest @@ -24,16 +23,30 @@ from beets.test.helper import TestHelper from beetsplug import random -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() + + +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.items.append(helper.create_item(artist=self.artist2)) self.random_gen = Random() self.random_gen.seed(12345) @@ -78,73 +91,94 @@ class RandomTest(TestHelper, unittest.TestCase): assert len(self.items) // 2 == pytest.approx(median2, abs=1) assert stdev2 > stdev1 - def test_equal_permutation_empty_input(self): + @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(random._equal_chance_permutation([], "artist")) - assert result == [] - - def test_equal_permutation_single_item(self): - """Test _equal_chance_permutation with single item.""" - result = list(random._equal_chance_permutation([self.item1], "artist")) - assert result == [self.item1] - - def test_equal_permutation_single_artist(self): - """Test _equal_chance_permutation with items from one artist.""" - items = [self.create_item(artist=self.artist1) for _ in range(5)] - result = list(random._equal_chance_permutation(items, "artist")) - assert set(result) == set(items) - assert len(result) == len(items) - - def test_random_objs_count(self): - """Test random_objs with count-based selection.""" - result = random.random_objs( - self.items, number=3, random_gen=self.random_gen + result = list( + random._equal_chance_permutation( + [helper.create_item(**i) for i in input_items], field + ) ) - assert len(result) == 3 - assert all(item in self.items for item in result) - def test_random_objs_time(self): - """Test random_objs with time-based selection.""" - # Total length is 30 + 60 + 8*45 = 450 seconds - # Requesting 120 seconds should return 2-3 items - result = random.random_objs( - self.items, - time=2, - random_gen=self.random_gen, # 2 minutes = 120 sec + 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 + ] + self.random_gen = random.Random() + + def test_random_selection_by_count(self): + """Test selecting a specific number of items.""" + selected = list(random.random_objs(self.items, 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.random_objs(self.items, 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.random_objs(self.items, number=10, equal_chance=True) ) - total_time = sum(item.length for item in result) - assert total_time <= 120 - # Check we got at least some items - assert len(result) > 0 + artist_counts = {} + for item in selected: + artist_counts[item.artist] = artist_counts.get(item.artist, 0) + 1 - def test_random_objs_equal_chance(self): - """Test random_objs with equal_chance=True.""" + # Ensure both artists are represented (not strictly equal due to randomness) + assert len(artist_counts) >= 2 - # With equal_chance, artist1 should appear more often in results - def experiment(): - """Run the random_objs function multiple times and collect results.""" - results = [] - for _ in range(5000): - result = random.random_objs( - [self.item1, self.item2], - number=1, - equal_chance=True, - random_gen=self.random_gen, - ) - results.append(result[0].artist) + def test_empty_input_list(self): + """Test behavior with an empty input list.""" + selected = list(random.random_objs([], number=1)) + assert len(selected) == 0 - # Return ratio - return results.count(self.artist1), results.count(self.artist2) - - count_artist1, count_artist2 = experiment() - assert 1 - count_artist1 / count_artist2 < 0.1 # 10% deviation - - def test_random_objs_empty_input(self): - """Test random_objs with empty input.""" - result = random.random_objs([], number=3) - assert result == [] - - def test_random_objs_zero_number(self): - """Test random_objs with number=0.""" - result = random.random_objs(self.items, number=0) - assert result == [] + def test_no_constraints_returns_all(self): + """Test that no constraints return all items in random order.""" + selected = list(random.random_objs(self.items, 3)) + assert len(selected) == len(self.items) + assert set(selected) == set(self.items) From 3dd6f5a25b82f6a13a83f582326f3bd9c365786a Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 13 Aug 2025 12:27:12 +0200 Subject: [PATCH 04/10] Cached property for length & forgot sorted. --- beets/library/models.py | 2 +- beetsplug/random.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/beets/library/models.py b/beets/library/models.py index e259f4e96..2118fded6 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -616,7 +616,7 @@ class Album(LibModel): for item in self.items(): item.try_sync(write, move) - @property + @cached_property def length(self) -> float: """Return the total length of all items in this album in seconds.""" return sum(item.length for item in self.items()) diff --git a/beetsplug/random.py b/beetsplug/random.py index 933049d96..853e9b14a 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -5,7 +5,7 @@ from __future__ import annotations import random from itertools import groupby, islice from operator import attrgetter -from typing import TYPE_CHECKING, Any, Iterable, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, Iterable, Sequence, Union from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ @@ -13,9 +13,9 @@ from beets.ui import Subcommand, print_ if TYPE_CHECKING: import optparse - from beets.library import LibModel, Library + from beets.library import Album, Item, Library - T = TypeVar("T", bound=LibModel) + T = Union[Item, Album] def random_func(lib: Library, opts: optparse.Values, args: list[str]): @@ -87,7 +87,9 @@ def _equal_chance_permutation( except AttributeError: return NOT_FOUND_SENTINEL - groups: dict[Any, list[T]] = { + sorted(objs, key=get_attr) + + groups: dict[str | object, list[T]] = { NOT_FOUND_SENTINEL: [], } for k, values in groupby(objs, key=get_attr): From 2aa7575294c4924bed9c5ebeb7b31888b9e0f545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 19 Aug 2025 15:00:41 +0100 Subject: [PATCH 05/10] Replace random.Random with random module --- beetsplug/random.py | 14 ++++---------- test/plugins/test_random.py | 32 ++++++++++++++------------------ 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index 853e9b14a..b8778eb08 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -70,14 +70,11 @@ NOT_FOUND_SENTINEL = object() def _equal_chance_permutation( objs: Sequence[T], field: str = "albumartist", - random_gen: random.Random | None = None, ) -> Iterable[T]: """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.Random = random_gen or random.Random() - # Group the objects by artist so we can sample from them. key = attrgetter(field) @@ -95,12 +92,12 @@ def _equal_chance_permutation( for k, values in groupby(objs, key=get_attr): groups[k] = list(values) # shuffle in category - rand.shuffle(groups[k]) + random.shuffle(groups[k]) # Remove items without the field value. del groups[NOT_FOUND_SENTINEL] while groups: - group = rand.choice(list(groups.keys())) + group = random.choice(list(groups.keys())) yield groups[group].pop() if not groups[group]: del groups[group] @@ -127,7 +124,6 @@ def random_objs( number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, - random_gen: random.Random | None = None, ) -> Iterable[T]: """Get a random subset of items, optionally constrained by time or count. @@ -140,16 +136,14 @@ def random_objs( selected, regardless of how many tracks they have. - random_gen: An optional random generator to use for shuffling. """ - rand: random.Random = random_gen or random.Random() - # Permute the objects either in a straightforward way or an # artist-balanced way. perm: Iterable[T] if equal_chance: - perm = _equal_chance_permutation(objs, random_gen=rand) + perm = _equal_chance_permutation(objs) else: perm = list(objs) - rand.shuffle(perm) + random.shuffle(perm) # Select objects by time our count. if time_minutes: diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index a7e57280a..e061473d0 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -15,12 +15,12 @@ """Test the beets.random utilities associated with the random plugin.""" import math -from random import Random +import random import pytest from beets.test.helper import TestHelper -from beetsplug import random +from beetsplug.random import _equal_chance_permutation, random_objs @pytest.fixture(scope="class") @@ -33,6 +33,11 @@ def helper(): helper.teardown_beets() +@pytest.fixture(scope="module", autouse=True) +def seed_random(): + random.seed(12345) + + class TestEqualChancePermutation: """Test the _equal_chance_permutation function.""" @@ -47,8 +52,6 @@ class TestEqualChancePermutation: self.items = [self.item1, self.item2] for _ in range(8): self.items.append(helper.create_item(artist=self.artist2)) - self.random_gen = Random() - self.random_gen.seed(12345) def _stats(self, data): mean = sum(data) / len(data) @@ -74,9 +77,7 @@ class TestEqualChancePermutation: 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). @@ -111,7 +112,7 @@ class TestEqualChancePermutation: ): """Test _equal_chance_permutation with empty input.""" result = list( - random._equal_chance_permutation( + _equal_chance_permutation( [helper.create_item(**i) for i in input_items], field ) ) @@ -136,19 +137,16 @@ class TestRandomObjs: helper.create_item(artist=self.artist2, length=240), # 4 minutes helper.create_item(artist=self.artist2, length=300), # 5 minutes ] - self.random_gen = random.Random() def test_random_selection_by_count(self): """Test selecting a specific number of items.""" - selected = list(random.random_objs(self.items, number=2)) + selected = list(random_objs(self.items, 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.random_objs(self.items, time_minutes=6) - ) # 6 minutes + selected = list(random_objs(self.items, time_minutes=6)) # 6 minutes total_time = ( sum(item.length for item in selected) / 60 ) # Convert to minutes @@ -162,9 +160,7 @@ class TestRandomObjs: helper.create_item(artist=self.artist1, length=180) ) - selected = list( - random.random_objs(self.items, number=10, equal_chance=True) - ) + selected = list(random_objs(self.items, number=10, equal_chance=True)) artist_counts = {} for item in selected: artist_counts[item.artist] = artist_counts.get(item.artist, 0) + 1 @@ -174,11 +170,11 @@ class TestRandomObjs: def test_empty_input_list(self): """Test behavior with an empty input list.""" - selected = list(random.random_objs([], number=1)) + selected = list(random_objs([], 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.random_objs(self.items, 3)) + selected = list(random_objs(self.items, 3)) assert len(selected) == len(self.items) assert set(selected) == set(self.items) From da9244d54d814c9eeda64ca8bb63746cd45964e5 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Fri, 22 Aug 2025 12:00:34 +0200 Subject: [PATCH 06/10] Added an option to define the field to use for equal chance sampling --- beetsplug/random.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index b8778eb08..42af2bb5d 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -29,6 +29,7 @@ def random_func(lib: Library, opts: optparse.Values, args: list[str]): number=opts.number, time_minutes=opts.time, equal_chance=opts.equal_chance, + equal_chance_field=opts.field, ): print_(format(obj)) @@ -55,6 +56,13 @@ random_cmd.parser.add_option( type="float", help="total length in minutes of objects to choose", ) +random_cmd.parser.add_option( + "-f", + "--field", + action="store", + type="string", + help="field to use for equal chance sampling (default: albumartist)", +) random_cmd.parser.add_all_common_options() random_cmd.func = random_func @@ -124,6 +132,7 @@ def random_objs( number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, + equal_chance_field: str = "albumartist", ) -> Iterable[T]: """Get a random subset of items, optionally constrained by time or count. @@ -140,7 +149,7 @@ def random_objs( # artist-balanced way. perm: Iterable[T] if equal_chance: - perm = _equal_chance_permutation(objs) + perm = _equal_chance_permutation(objs, field=equal_chance_field) else: perm = list(objs) random.shuffle(perm) From 5ed0a723106457073022aa48f6dc59beb56949de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 26 Aug 2025 12:24:33 +0100 Subject: [PATCH 07/10] Add annotation for LibModel.length property --- beets/library/models.py | 3 ++- beetsplug/random.py | 27 ++++++++++++--------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/beets/library/models.py b/beets/library/models.py index 2118fded6..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]: @@ -617,7 +618,7 @@ class Album(LibModel): item.try_sync(write, move) @cached_property - def length(self) -> float: + 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()) diff --git a/beetsplug/random.py b/beetsplug/random.py index 42af2bb5d..ba687f477 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -5,7 +5,7 @@ from __future__ import annotations import random from itertools import groupby, islice from operator import attrgetter -from typing import TYPE_CHECKING, Any, Iterable, Sequence, Union +from typing import TYPE_CHECKING, Any, Iterable from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ @@ -13,9 +13,7 @@ from beets.ui import Subcommand, print_ if TYPE_CHECKING: import optparse - from beets.library import Album, Item, Library - - T = Union[Item, Album] + from beets.library import LibModel, Library def random_func(lib: Library, opts: optparse.Values, args: list[str]): @@ -25,7 +23,7 @@ def random_func(lib: Library, opts: optparse.Values, args: list[str]): # Print a random subset. for obj in random_objs( - objs=list(objs), + objs=objs, number=opts.number, time_minutes=opts.time, equal_chance=opts.equal_chance, @@ -76,9 +74,8 @@ NOT_FOUND_SENTINEL = object() def _equal_chance_permutation( - objs: Sequence[T], - field: str = "albumartist", -) -> Iterable[T]: + objs: Iterable[LibModel], field: str = "albumartist" +) -> 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. @@ -86,7 +83,7 @@ def _equal_chance_permutation( # Group the objects by artist so we can sample from them. key = attrgetter(field) - def get_attr(obj: T) -> Any: + def get_attr(obj: LibModel) -> Any: try: return key(obj) except AttributeError: @@ -94,7 +91,7 @@ def _equal_chance_permutation( sorted(objs, key=get_attr) - groups: dict[str | object, list[T]] = { + groups: dict[str | object, list[LibModel]] = { NOT_FOUND_SENTINEL: [], } for k, values in groupby(objs, key=get_attr): @@ -112,9 +109,9 @@ def _equal_chance_permutation( def _take_time( - iter: Iterable[T], + iter: Iterable[LibModel], secs: float, -) -> Iterable[T]: +) -> 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. @@ -128,12 +125,12 @@ def _take_time( def random_objs( - objs: Sequence[T], + objs: Iterable[LibModel], number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, equal_chance_field: str = "albumartist", -) -> Iterable[T]: +) -> Iterable[LibModel]: """Get a random subset of items, optionally constrained by time or count. Args: @@ -147,7 +144,7 @@ def random_objs( """ # Permute the objects either in a straightforward way or an # artist-balanced way. - perm: Iterable[T] + perm: Iterable[LibModel] if equal_chance: perm = _equal_chance_permutation(objs, field=equal_chance_field) else: From 6c52252672862abb7bc80b141560aecc858da388 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 7 Jan 2026 15:57:48 +0100 Subject: [PATCH 08/10] Readded licence. Removed last legacy occurrences of `artist` and replaced them with `field`. Removed unnecessary default parameters where applicable. --- beetsplug/random.py | 37 +++++++++++++++++++++++++------------ test/plugins/test_random.py | 14 +++++++++----- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index ba687f477..9714c8e53 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -1,17 +1,31 @@ -"""Get a random song or album from the library.""" +# 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 +# "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. from __future__ import annotations import random from itertools import groupby, islice from operator import attrgetter -from typing import TYPE_CHECKING, Any, Iterable +from typing import TYPE_CHECKING, Any from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ if TYPE_CHECKING: import optparse + from collections.abc import Iterable from beets.library import LibModel, Library @@ -24,10 +38,10 @@ def random_func(lib: Library, opts: optparse.Values, args: list[str]): # Print a random subset. for obj in random_objs( objs=objs, + equal_chance_field=opts.field, number=opts.number, time_minutes=opts.time, equal_chance=opts.equal_chance, - equal_chance_field=opts.field, ): print_(format(obj)) @@ -45,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", @@ -55,10 +69,10 @@ random_cmd.parser.add_option( help="total length in minutes of objects to choose", ) random_cmd.parser.add_option( - "-f", "--field", action="store", type="string", + default="albumartist", help="field to use for equal chance sampling (default: albumartist)", ) random_cmd.parser.add_all_common_options() @@ -74,13 +88,13 @@ NOT_FOUND_SENTINEL = object() def _equal_chance_permutation( - objs: Iterable[LibModel], field: str = "albumartist" + 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 artist so we can sample from them. + # Group the objects by field so we can sample from them. key = attrgetter(field) def get_attr(obj: LibModel) -> Any: @@ -126,10 +140,10 @@ def _take_time( def random_objs( objs: Iterable[LibModel], + equal_chance_field: str, number: int = 1, time_minutes: float | None = None, equal_chance: bool = False, - equal_chance_field: str = "albumartist", ) -> Iterable[LibModel]: """Get a random subset of items, optionally constrained by time or count. @@ -138,15 +152,14 @@ def random_objs( - 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 artist has the same chance of being + - 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 - # artist-balanced way. - perm: Iterable[LibModel] + # field-balanced way. if equal_chance: - perm = _equal_chance_permutation(objs, field=equal_chance_field) + perm = _equal_chance_permutation(objs, equal_chance_field) else: perm = list(objs) random.shuffle(perm) diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py index e061473d0..975bb8ffa 100644 --- a/test/plugins/test_random.py +++ b/test/plugins/test_random.py @@ -140,13 +140,15 @@ class TestRandomObjs: def test_random_selection_by_count(self): """Test selecting a specific number of items.""" - selected = list(random_objs(self.items, number=2)) + 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, time_minutes=6)) # 6 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 @@ -160,7 +162,9 @@ class TestRandomObjs: helper.create_item(artist=self.artist1, length=180) ) - selected = list(random_objs(self.items, number=10, equal_chance=True)) + 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 @@ -170,11 +174,11 @@ class TestRandomObjs: def test_empty_input_list(self): """Test behavior with an empty input list.""" - selected = list(random_objs([], number=1)) + 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, 3)) + selected = list(random_objs(self.items, "artist", number=3)) assert len(selected) == len(self.items) assert set(selected) == set(self.items) From ee7dc3c4e7e2334ff89fe5f68fa818bcb6c6d8f8 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Wed, 7 Jan 2026 16:08:36 +0100 Subject: [PATCH 09/10] Enhanced documentation of random plugin. --- docs/changelog.rst | 2 + docs/plugins/random.rst | 123 ++++++++++++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6ccdd6060..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: 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 From 95cef2de2b97ec0949ebfc5b415b3d49d5b16112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 30 Jan 2026 00:18:02 +0000 Subject: [PATCH 10/10] Fix grouping for list fields and stabilize equal-chance order - Handle list-valued fields when grouping for --field/--equal-chance to avoid "TypeError: unhashable type: 'list'" (e.g., artists). - Sort items by the grouping key before building groups so equal-chance permutation preserves the same item set as `beet list`, only randomized. --- beetsplug/random.py | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/beetsplug/random.py b/beetsplug/random.py index 9714c8e53..aa65dd5d5 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -17,8 +17,8 @@ from __future__ import annotations import random from itertools import groupby, islice -from operator import attrgetter -from typing import TYPE_CHECKING, Any +from operator import methodcaller +from typing import TYPE_CHECKING from beets.plugins import BeetsPlugin from beets.ui import Subcommand, print_ @@ -84,9 +84,6 @@ class Random(BeetsPlugin): return [random_cmd] -NOT_FOUND_SENTINEL = object() - - def _equal_chance_permutation( objs: Iterable[LibModel], field: str ) -> Iterable[LibModel]: @@ -95,26 +92,16 @@ def _equal_chance_permutation( any given position. """ # Group the objects by field so we can sample from them. - key = attrgetter(field) + get_attr = methodcaller("get", field) - def get_attr(obj: LibModel) -> Any: - try: - return key(obj) - except AttributeError: - return NOT_FOUND_SENTINEL + 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 - sorted(objs, key=get_attr) - - groups: dict[str | object, list[LibModel]] = { - NOT_FOUND_SENTINEL: [], - } - for k, values in groupby(objs, key=get_attr): - groups[k] = list(values) - # shuffle in category - random.shuffle(groups[k]) - - # Remove items without the field value. - del groups[NOT_FOUND_SENTINEL] while groups: group = random.choice(list(groups.keys())) yield groups[group].pop()