Refactored beets/random.py and moved into beetsplug/random.py (#5924)

The `beets/random.py` module was only used by the random plugin, so I
moved its functions into `beetsplug/random.py` to keep core modules
cleaner.


Changes:
- Moved beets/random.py functions into beetsplug/random.py
- Added typehints for better readability and tooling support
- Added additional tests for improved coverage
- General tidy up and refactor, keeping the core functionality unchanged
This commit is contained in:
Šarūnas Nejus 2026-01-30 00:36:51 +00:00 committed by GitHub
commit c1484169dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 339 additions and 154 deletions

View file

@ -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."""

View file

@ -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)

View file

@ -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)

View file

@ -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)
------------------------

View file

@ -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

View file

@ -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)