Skip to content

Commit 3640c82

Browse files
committed
Overall refactor of random plugin. Added length property to albums.
1 parent 3941fd3 commit 3640c82

File tree

3 files changed

+173
-154
lines changed

3 files changed

+173
-154
lines changed

beets/library/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,11 @@ def try_sync(self, write, move, inherit=True):
616616
for item in self.items():
617617
item.try_sync(write, move)
618618

619+
@property
620+
def length(self) -> float:
621+
"""Return the total length of all items in this album in seconds."""
622+
return sum(item.length for item in self.items())
623+
619624

620625
class Item(LibModel):
621626
"""Represent a song or track."""

beetsplug/random.py

Lines changed: 63 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,34 @@
1-
# This file is part of beets.
2-
# Copyright 2016, Philippe Mongeau.
3-
#
4-
# Permission is hereby granted, free of charge, to any person obtaining
5-
# a copy of this software and associated documentation files (the
6-
# "Software"), to deal in the Software without restriction, including
7-
# without limitation the rights to use, copy, modify, merge, publish,
8-
# distribute, sublicense, and/or sell copies of the Software, and to
9-
# permit persons to whom the Software is furnished to do so, subject to
10-
# the following conditions:
11-
#
12-
# The above copyright notice and this permission notice shall be
13-
# included in all copies or substantial portions of the Software.
14-
151
"""Get a random song or album from the library."""
162

173
from __future__ import annotations
184

195
import random
206
from itertools import groupby, islice
217
from operator import attrgetter
22-
from typing import Iterable, Sequence, TypeVar
8+
from typing import TYPE_CHECKING, Any, Iterable, Sequence, TypeVar
239

24-
from beets.library import Album, Item
2510
from beets.plugins import BeetsPlugin
2611
from beets.ui import Subcommand, print_
2712

13+
if TYPE_CHECKING:
14+
import optparse
15+
16+
from beets.library import Album, Item, Library
17+
18+
T = TypeVar("T", bound=Item | Album)
19+
2820

29-
def random_func(lib, opts, args):
21+
def random_func(lib: Library, opts: optparse.Values, args: list[str]):
3022
"""Select some random items or albums and print the results."""
3123
# Fetch all the objects matching the query into a list.
32-
if opts.album:
33-
objs = list(lib.albums(args))
34-
else:
35-
objs = list(lib.items(args))
24+
objs = lib.albums(args) if opts.album else lib.items(args)
3625

3726
# Print a random subset.
3827
objs = random_objs(
39-
objs, opts.album, opts.number, opts.time, opts.equal_chance
28+
objs=list(objs),
29+
number=opts.number,
30+
time_minutes=opts.time,
31+
equal_chance=opts.equal_chance,
4032
)
4133
for obj in objs:
4234
print_(format(obj))
@@ -73,105 +65,93 @@ def commands(self):
7365
return [random_cmd]
7466

7567

76-
def _length(obj: Item | Album) -> float:
77-
"""Get the duration of an item or album."""
78-
if isinstance(obj, Album):
79-
return sum(i.length for i in obj.items())
80-
else:
81-
return obj.length
68+
NOT_FOUND_SENTINEL = object()
8269

8370

8471
def _equal_chance_permutation(
85-
objs: Sequence[Item | Album],
72+
objs: Sequence[T],
8673
field: str = "albumartist",
8774
random_gen: random.Random | None = None,
88-
) -> Iterable[Item | Album]:
75+
) -> Iterable[T]:
8976
"""Generate (lazily) a permutation of the objects where every group
9077
with equal values for `field` have an equal chance of appearing in
9178
any given position.
9279
"""
93-
rand = random_gen or random
80+
rand: random.Random = random_gen or random.Random()
9481

9582
# Group the objects by artist so we can sample from them.
9683
key = attrgetter(field)
97-
objs = sorted(objs, key=key)
98-
objs_by_artists = {}
99-
for artist, v in groupby(objs, key):
100-
objs_by_artists[artist] = list(v)
101-
102-
# While we still have artists with music to choose from, pick one
103-
# randomly and pick a track from that artist.
104-
while objs_by_artists:
105-
# Choose an artist and an object for that artist, removing
106-
# this choice from the pool.
107-
artist = rand.choice(list(objs_by_artists.keys()))
108-
objs_from_artist = objs_by_artists[artist]
109-
i = rand.randint(0, len(objs_from_artist) - 1)
110-
yield objs_from_artist.pop(i)
11184

112-
# Remove the artist if we've used up all of its objects.
113-
if not objs_from_artist:
114-
del objs_by_artists[artist]
115-
116-
117-
T = TypeVar("T")
118-
119-
120-
def _take(
121-
iter: Iterable[T],
122-
num: int,
123-
) -> list[T]:
124-
"""Return a list containing the first `num` values in `iter` (or
125-
fewer, if the iterable ends early).
126-
"""
127-
return list(islice(iter, num))
85+
def get_attr(obj: T) -> Any:
86+
try:
87+
return key(obj)
88+
except AttributeError:
89+
return NOT_FOUND_SENTINEL
90+
91+
groups: dict[Any, list[T]] = {
92+
NOT_FOUND_SENTINEL: [],
93+
}
94+
for k, values in groupby(objs, key=get_attr):
95+
groups[k] = list(values)
96+
# shuffle in category
97+
rand.shuffle(groups[k])
98+
99+
# Remove items without the field value.
100+
del groups[NOT_FOUND_SENTINEL]
101+
while groups:
102+
group = rand.choice(list(groups.keys()))
103+
yield groups[group].pop()
104+
if not groups[group]:
105+
del groups[group]
128106

129107

130108
def _take_time(
131-
iter: Iterable[Item | Album],
109+
iter: Iterable[T],
132110
secs: float,
133-
) -> list[Item | Album]:
111+
) -> Iterable[T]:
134112
"""Return a list containing the first values in `iter`, which should
135113
be Item or Album objects, that add up to the given amount of time in
136114
seconds.
137115
"""
138-
out: list[Item | Album] = []
139116
total_time = 0.0
140117
for obj in iter:
141-
length = _length(obj)
118+
length = obj.length
142119
if total_time + length <= secs:
143-
out.append(obj)
120+
yield obj
144121
total_time += length
145-
return out
146122

147123

148124
def random_objs(
149-
objs: Sequence[Item | Album],
150-
number=1,
151-
time: float | None = None,
125+
objs: Sequence[T],
126+
number: int = 1,
127+
time_minutes: float | None = None,
152128
equal_chance: bool = False,
153129
random_gen: random.Random | None = None,
154-
):
155-
"""Get a random subset of the provided `objs`.
156-
157-
If `number` is provided, produce that many matches. Otherwise, if
158-
`time` is provided, instead select a list whose total time is close
159-
to that number of minutes. If `equal_chance` is true, give each
160-
artist an equal chance of being included so that artists with more
161-
songs are not represented disproportionately.
130+
) -> Iterable[T]:
131+
"""Get a random subset of items, optionally constrained by time or count.
132+
133+
Args:
134+
- objs: The sequence of objects to choose from.
135+
- number: The number of objects to select.
136+
- time_minutes: If specified, the total length of selected objects
137+
should not exceed this many minutes.
138+
- equal_chance: If True, each artist has the same chance of being
139+
selected, regardless of how many tracks they have.
140+
- random_gen: An optional random generator to use for shuffling.
162141
"""
163-
rand = random_gen or random
142+
rand: random.Random = random_gen or random.Random()
164143

165144
# Permute the objects either in a straightforward way or an
166145
# artist-balanced way.
146+
perm: Iterable[T]
167147
if equal_chance:
168-
perm = _equal_chance_permutation(objs)
148+
perm = _equal_chance_permutation(objs, random_gen=rand)
169149
else:
170150
perm = list(objs)
171151
rand.shuffle(perm)
172152

173153
# Select objects by time our count.
174-
if time:
175-
return _take_time(perm, time * 60)
154+
if time_minutes:
155+
return _take_time(perm, time_minutes * 60)
176156
else:
177-
return _take(perm, number)
157+
return islice(perm, number)

0 commit comments

Comments
 (0)