mirror of
https://github.com/beetbox/beets.git
synced 2026-02-09 00:41:57 +01:00
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:
commit
c1484169dc
6 changed files with 339 additions and 154 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
112
beets/random.py
112
beets/random.py
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue