|
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 |
| - |
15 | 1 | """Get a random song or album from the library."""
|
16 | 2 |
|
17 | 3 | from __future__ import annotations
|
18 | 4 |
|
19 | 5 | import random
|
20 | 6 | from itertools import groupby, islice
|
21 | 7 | from operator import attrgetter
|
22 |
| -from typing import Iterable, Sequence, TypeVar |
| 8 | +from typing import TYPE_CHECKING, Any, Iterable, Sequence, TypeVar |
23 | 9 |
|
24 |
| -from beets.library import Album, Item |
25 | 10 | from beets.plugins import BeetsPlugin
|
26 | 11 | from beets.ui import Subcommand, print_
|
27 | 12 |
|
| 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 | + |
28 | 20 |
|
29 |
| -def random_func(lib, opts, args): |
| 21 | +def random_func(lib: Library, opts: optparse.Values, args: list[str]): |
30 | 22 | """Select some random items or albums and print the results."""
|
31 | 23 | # 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) |
36 | 25 |
|
37 | 26 | # Print a random subset.
|
38 | 27 | 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, |
40 | 32 | )
|
41 | 33 | for obj in objs:
|
42 | 34 | print_(format(obj))
|
@@ -73,105 +65,93 @@ def commands(self):
|
73 | 65 | return [random_cmd]
|
74 | 66 |
|
75 | 67 |
|
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() |
82 | 69 |
|
83 | 70 |
|
84 | 71 | def _equal_chance_permutation(
|
85 |
| - objs: Sequence[Item | Album], |
| 72 | + objs: Sequence[T], |
86 | 73 | field: str = "albumartist",
|
87 | 74 | random_gen: random.Random | None = None,
|
88 |
| -) -> Iterable[Item | Album]: |
| 75 | +) -> Iterable[T]: |
89 | 76 | """Generate (lazily) a permutation of the objects where every group
|
90 | 77 | with equal values for `field` have an equal chance of appearing in
|
91 | 78 | any given position.
|
92 | 79 | """
|
93 |
| - rand = random_gen or random |
| 80 | + rand: random.Random = random_gen or random.Random() |
94 | 81 |
|
95 | 82 | # Group the objects by artist so we can sample from them.
|
96 | 83 | 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) |
111 | 84 |
|
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] |
128 | 106 |
|
129 | 107 |
|
130 | 108 | def _take_time(
|
131 |
| - iter: Iterable[Item | Album], |
| 109 | + iter: Iterable[T], |
132 | 110 | secs: float,
|
133 |
| -) -> list[Item | Album]: |
| 111 | +) -> Iterable[T]: |
134 | 112 | """Return a list containing the first values in `iter`, which should
|
135 | 113 | be Item or Album objects, that add up to the given amount of time in
|
136 | 114 | seconds.
|
137 | 115 | """
|
138 |
| - out: list[Item | Album] = [] |
139 | 116 | total_time = 0.0
|
140 | 117 | for obj in iter:
|
141 |
| - length = _length(obj) |
| 118 | + length = obj.length |
142 | 119 | if total_time + length <= secs:
|
143 |
| - out.append(obj) |
| 120 | + yield obj |
144 | 121 | total_time += length
|
145 |
| - return out |
146 | 122 |
|
147 | 123 |
|
148 | 124 | 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, |
152 | 128 | equal_chance: bool = False,
|
153 | 129 | 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. |
162 | 141 | """
|
163 |
| - rand = random_gen or random |
| 142 | + rand: random.Random = random_gen or random.Random() |
164 | 143 |
|
165 | 144 | # Permute the objects either in a straightforward way or an
|
166 | 145 | # artist-balanced way.
|
| 146 | + perm: Iterable[T] |
167 | 147 | if equal_chance:
|
168 |
| - perm = _equal_chance_permutation(objs) |
| 148 | + perm = _equal_chance_permutation(objs, random_gen=rand) |
169 | 149 | else:
|
170 | 150 | perm = list(objs)
|
171 | 151 | rand.shuffle(perm)
|
172 | 152 |
|
173 | 153 | # 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) |
176 | 156 | else:
|
177 |
| - return _take(perm, number) |
| 157 | + return islice(perm, number) |
0 commit comments