Skip to content

Commit 373e372

Browse files
committed
Add progress bars to several commands and plugins.
Many long-running commands produce little or no feedback in the terminal to indicate that they're progressing, and none of them provide estimates of how long the operation will run. This change introduces the `enlighten` python package, which displays progress bars akin to TQDM below the existing terminal output. To support consistent use and presentation of the progress bars, and to allow for future modification, we introduce a method to beets.ui - beets.ui.iprogress_bar - which can be used by Beets' core commands and all Beets plugins. Example usage is provided in the methods' documentation. The Enlighten library does not work as well in Windows PowerShell as it does in a linux terminal (manually tested in Zsh), so the progress bars are disabled in Windows environments. Resolving these issues and enabling them in Windows is left as future work.
1 parent 2b4758d commit 373e372

21 files changed

+607
-99
lines changed

beets/ui/__init__.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,29 @@
3232
from difflib import SequenceMatcher
3333
from functools import cache
3434
from itertools import chain
35-
from typing import Any, Callable, Literal
35+
from typing import (
36+
Any,
37+
Callable,
38+
Generator,
39+
Iterable,
40+
Literal,
41+
Sequence,
42+
TypeVar,
43+
)
3644

3745
import confuse
46+
import enlighten
3847

3948
from beets import config, library, logging, plugins, util
4049
from beets.dbcore import db
4150
from beets.dbcore import query as db_query
4251
from beets.util import as_string
4352
from beets.util.functemplate import template
4453

54+
is_windows = sys.platform == "win32"
55+
4556
# On Windows platforms, use colorama to support "ANSI" terminal colors.
46-
if sys.platform == "win32":
57+
if is_windows:
4758
try:
4859
import colorama
4960
except ImportError:
@@ -1325,6 +1336,60 @@ def add_all_common_options(self):
13251336
self.add_format_option()
13261337

13271338

1339+
M = library.Album | library.Item | Any
1340+
def iprogress_bar(sequence: Sequence[M], **kwargs) -> Generator[M, None, None]:
1341+
"""Construct and manage an `enlighten.Counter` progress bar while iterating.
1342+
1343+
Example usage:
1344+
```
1345+
for album in ui.iprogress_bar(
1346+
lib.albums(), desc="Updating albums", unit="albums"):
1347+
do_something_to(album)
1348+
```
1349+
1350+
If the progress bar is iterating over an Album or an Item, then it will detect
1351+
whether or not the item has been modified, and will color-code the progress bar
1352+
with white and blue to indicate total progress and the portion of items that have
1353+
been modified.
1354+
1355+
Args:
1356+
sequence: An `Iterable` sequence to iterate over. If provided, and the
1357+
sequence can return its length, then the length will be used as the
1358+
total for the counter. The counter will be updated for each item
1359+
in the sequence.
1360+
kwargs: Additional keyword arguments to pass to the `enlighten.Counter`
1361+
constructor.
1362+
1363+
Yields:
1364+
The items from the sequence.
1365+
"""
1366+
if sequence is None:
1367+
log.error("sequence must not be None")
1368+
return
1369+
1370+
# If sequence is not None, and can return its length, then use that as the total.
1371+
if "total" not in kwargs and hasattr(sequence, "__len__"):
1372+
kwargs["total"] = len(sequence)
1373+
1374+
# Disabled in windows environments. See above for details
1375+
with enlighten.Manager(enabled=not is_windows) as manager:
1376+
with manager.counter(**kwargs) as counter:
1377+
change_counter = counter.add_subcounter("blue")
1378+
1379+
for item in sequence:
1380+
revision = None
1381+
if hasattr(item, '_revision'):
1382+
revision = item._revision
1383+
1384+
# Yield the item, allowing it to be modified, or not.
1385+
yield item
1386+
1387+
if revision and item._revision != revision:
1388+
change_counter.update()
1389+
else:
1390+
counter.update()
1391+
1392+
13281393
# Subcommand parsing infrastructure.
13291394
#
13301395
# This is a fairly generic subcommand parser for optparse. It is

beetsplug/autobpm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import numpy as np
2222

2323
from beets.plugins import BeetsPlugin
24-
from beets.ui import Subcommand, should_write
24+
from beets.ui import Subcommand, iprogress_bar, should_write
2525

2626
if TYPE_CHECKING:
2727
from beets.importer import ImportTask
@@ -56,7 +56,7 @@ def imported(self, _, task: ImportTask) -> None:
5656
self.calculate_bpm(task.imported_items())
5757

5858
def calculate_bpm(self, items: list[Item], write: bool = False) -> None:
59-
for item in items:
59+
for item in iprogress_bar(items, desc="Calculating BPM", unit="items"):
6060
path = item.filepath
6161
if bpm := item.bpm:
6262
self._log.info("BPM for {} already exists: {}", path, bpm)

beetsplug/badfiles.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def check_item(self, item):
137137
error_lines = []
138138

139139
if status > 0:
140+
success = False
140141
error_lines.append(
141142
f"{ui.colorize('text_error', dpath)}: checker exited with"
142143
f" status {status}"
@@ -145,16 +146,18 @@ def check_item(self, item):
145146
error_lines.append(f" {line}")
146147

147148
elif errors > 0:
149+
success = False
148150
error_lines.append(
149151
f"{ui.colorize('text_warning', dpath)}: checker found"
150152
f" {status} errors or warnings"
151153
)
152154
for line in output:
153155
error_lines.append(f" {line}")
154156
elif self.verbose:
157+
success = True
155158
error_lines.append(f"{ui.colorize('text_success', dpath)}: ok")
156159

157-
return error_lines
160+
return success, error_lines
158161

159162
def on_import_task_start(self, task, session):
160163
if not self.config["check_on_import"].get(False):
@@ -163,9 +166,8 @@ def on_import_task_start(self, task, session):
163166
checks_failed = []
164167

165168
for item in task.items:
166-
error_lines = self.check_item(item)
167-
if error_lines:
168-
checks_failed.append(error_lines)
169+
_, error_lines = self.check_item(item)
170+
checks_failed.append(error_lines)
169171

170172
if checks_failed:
171173
task._badfiles_checks_failed = checks_failed
@@ -200,8 +202,19 @@ def command(self, lib, opts, args):
200202
self.verbose = opts.verbose
201203

202204
def check_and_print(item):
203-
for error_line in self.check_item(item):
204-
ui.print_(error_line)
205+
with ui.changes_and_errors_pbars(
206+
total=len(item),
207+
desc="Checking item",
208+
unit="items",
209+
color="white",
210+
) as (_, n_unchanged, n_errors):
211+
success, error_lines = self.check_item(item)
212+
if success:
213+
n_unchanged.update()
214+
else:
215+
n_errors.update()
216+
for line in error_lines:
217+
ui.print_(line)
205218

206219
par_map(check_and_print, items)
207220

beetsplug/chroma.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,11 @@ def submit_cmd_func(lib, opts, args):
254254
)
255255

256256
def fingerprint_cmd_func(lib, opts, args):
257-
for item in lib.items(args):
257+
for item in ui.iprogress_bar(
258+
lib.items(args),
259+
desc="Fingerprinting items",
260+
unit="items",
261+
):
258262
fingerprint_item(self._log, item, write=ui.should_write())
259263

260264
fingerprint_cmd.func = fingerprint_cmd_func

beetsplug/duplicates.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import os
1818
import shlex
1919

20+
from beets import ui
2021
from beets.library import Album, Item
2122
from beets.plugins import BeetsPlugin
2223
from beets.ui import Subcommand, UserError, print_
@@ -284,9 +285,13 @@ def _group_by(self, objs, keys, strict):
284285
import collections
285286

286287
counts = collections.defaultdict(list)
287-
for obj in objs:
288+
for obj in ui.iprogress_bar(
289+
objs,
290+
desc="Finding duplicates",
291+
unit="items",
292+
):
288293
values = [getattr(obj, k, None) for k in keys]
289-
values = [v for v in values if v not in (None, "")]
294+
values = list(filter(lambda v: v not in (None, ""), values))
290295
if strict and len(values) < len(keys):
291296
self._log.debug(
292297
"some keys {} on item {.filepath} are null or empty: skipping",

beetsplug/embedart.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ def embed_func(lib, opts, args):
120120
if not opts.yes and not _confirm(items, not opts.file):
121121
return
122122

123-
for item in items:
123+
for item in ui.iprogress_bar(
124+
items,
125+
desc="Embedding artwork",
126+
unit="items",
127+
):
124128
art.embed_item(
125129
self._log,
126130
item,
@@ -155,7 +159,11 @@ def embed_func(lib, opts, args):
155159
if not opts.yes and not _confirm(items, not opts.url):
156160
os.remove(tempimg)
157161
return
158-
for item in items:
162+
for item in ui.iprogress_bar(
163+
items,
164+
desc="Embedding artwork",
165+
unit="items",
166+
):
159167
art.embed_item(
160168
self._log,
161169
item,
@@ -172,7 +180,11 @@ def embed_func(lib, opts, args):
172180
# Confirm with user.
173181
if not opts.yes and not _confirm(albums, not opts.file):
174182
return
175-
for album in albums:
183+
for album in ui.iprogress_bar(
184+
albums,
185+
desc="Embedding artwork",
186+
unit="albums",
187+
):
176188
art.embed_album(
177189
self._log,
178190
album,

beetsplug/fetchart.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1564,7 +1564,12 @@ def batch_fetch_art(
15641564
"""Fetch album art for each of the albums. This implements the manual
15651565
fetchart CLI command.
15661566
"""
1567-
for album in albums:
1567+
1568+
for album in ui.iprogress_bar(
1569+
albums,
1570+
desc="Fetching album art",
1571+
unit="albums",
1572+
):
15681573
if (
15691574
album.artpath
15701575
and not force

beetsplug/ftintitle.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,11 @@ def func(lib, opts, args):
117117
keep_in_artist_field = self.config["keep_in_artist"].get(bool)
118118
write = ui.should_write()
119119

120-
for item in lib.items(args):
120+
for item in ui.iprogress_bar(
121+
lib.items(args),
122+
desc="Analyzing songs",
123+
unit="songs",
124+
):
121125
if self.ft_in_title(item, drop_feat, keep_in_artist_field):
122126
item.store()
123127
if write:

beetsplug/lastgenre/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,11 @@ def lastgenre_func(lib, opts, args):
525525

526526
if opts.album:
527527
# Fetch genres for whole albums
528-
for album in lib.albums(args):
528+
for album in ui.iprogress_bar(
529+
lib.albums(args),
530+
desc="Fetching genres",
531+
unit="albums",
532+
):
529533
album.genre, src = self._get_genre(album)
530534
self._log.info(
531535
'genre for album "{0.album}" ({1}): {0.genre}',
@@ -554,7 +558,11 @@ def lastgenre_func(lib, opts, args):
554558
else:
555559
# Just query singletons, i.e. items that are not part of
556560
# an album
557-
for item in lib.items(args):
561+
for item in ui.iprogress_bar(
562+
lib.items(args),
563+
desc="Fetching genres",
564+
unit="tracks",
565+
):
558566
item.genre, src = self._get_genre(item)
559567
item.store()
560568
self._log.info(

beetsplug/lyrics.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1048,7 +1048,11 @@ def func(lib: Library, opts, args) -> None:
10481048
# import_write config value.
10491049
self.config.set(vars(opts))
10501050
items = list(lib.items(args))
1051-
for item in items:
1051+
for item in ui.iprogress_bar(
1052+
items,
1053+
desc="Fetching lyrics",
1054+
unit="items",
1055+
):
10521056
self.add_item_lyrics(item, ui.should_write())
10531057
if item.lyrics and opts.print:
10541058
ui.print_(item.lyrics)

0 commit comments

Comments
 (0)