Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
974d917
fix(fetchart): prevent deletion of configured fallback cover art
dtrunk90 Jan 12, 2026
f996ad4
Add progress bars to several commands and plugins.
peterjdolan Mar 26, 2025
3f4541c
Add progress bar to 'beet mv', and handle missing file exceptions wit…
peterjdolan Oct 14, 2025
384e928
Add progress bars to 'beet update', 'beet rm', 'beet mv', and the mod…
peterjdolan Oct 14, 2025
204ec29
Code review feedback
peterjdolan Mar 2, 2026
4b8837b
Revise documentation formatting
peterjdolan Mar 2, 2026
31bbd1f
fix(fetchart): prevent deletion of configured fallback cover art (#6283)
snejus Mar 2, 2026
9d8a06c
Review feedback
peterjdolan Mar 2, 2026
723b4bb
fix(lastgenre): Reset plugin config in fixtured tests
Nukesor Mar 2, 2026
86fac4b
fix(lastgenre): Reset plugin config in fixtured tests (#6386)
snejus Mar 2, 2026
15d4401
Review feedback
peterjdolan Mar 2, 2026
3fd110e
Fix poetry, and lint
peterjdolan Mar 3, 2026
c3720af
Formatting
peterjdolan Mar 3, 2026
5fe3740
Plural units -> singular units
peterjdolan Mar 3, 2026
51ccc89
Fix units
peterjdolan Mar 3, 2026
994144b
Fix tests
peterjdolan Mar 3, 2026
1f08b32
Formatting
peterjdolan Mar 3, 2026
7b86304
Fix small counter error
peterjdolan Mar 3, 2026
c61cb9e
Fix import
peterjdolan Mar 3, 2026
8b51ca5
Add progress bars to several commands and plugins.
peterjdolan Mar 26, 2025
b85e77a
Add progress bar to 'beet mv', and handle missing file exceptions wit…
peterjdolan Oct 14, 2025
46ad63c
Add progress bars to 'beet update', 'beet rm', 'beet mv', and the mod…
peterjdolan Oct 14, 2025
a1299f1
Code review feedback
peterjdolan Mar 2, 2026
eb7aa50
Revise documentation formatting
peterjdolan Mar 2, 2026
71ff77e
Review feedback
peterjdolan Mar 2, 2026
3904e33
Review feedback
peterjdolan Mar 2, 2026
403ba4d
Fix poetry, and lint
peterjdolan Mar 3, 2026
aebe5b6
Formatting
peterjdolan Mar 3, 2026
eb01939
Plural units -> singular units
peterjdolan Mar 3, 2026
47fff1f
Fix units
peterjdolan Mar 3, 2026
049deac
Fix tests
peterjdolan Mar 3, 2026
157c668
Formatting
peterjdolan Mar 3, 2026
e8ad9d1
Fix small counter error
peterjdolan Mar 3, 2026
6e20d3a
Fix import
peterjdolan Mar 3, 2026
3c5ced9
Merge branch 'tqdm' of https://github.com/peterjdolan/beets into tqdm
peterjdolan Mar 3, 2026
0d437d1
Format progressbars.rst
peterjdolan Mar 3, 2026
d74e291
Revert unintended change to format-docs command
peterjdolan Mar 3, 2026
e66c89a
Check if revision is not None, as it will be 0 for items not net writ…
peterjdolan Mar 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion beets/library/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
import platformdirs

import beets
from beets import dbcore
from beets import dbcore, ui
from beets.util import normpath

from .migrations import MultiGenreFieldMigration
from .models import Album, Item
from .queries import PF_KEY_DEFAULT, parse_query_parts, parse_query_string

if TYPE_CHECKING:
from collections.abc import Iterator

from beets.dbcore import Results


Expand Down Expand Up @@ -126,6 +128,58 @@ def items(self, query=None, sort=None) -> Results[Item]:
"""Get :class:`Item` objects matching the query."""
return self._fetch(Item, query, sort or self.get_default_item_sort())

def items_with_progress(
self,
desc: str,
query=None,
sort=None,
unit: str = "item",
) -> Iterator[Item]:
"""Iterate over items while displaying a progress bar.

Args:
desc: The description of the progress bar. Semantically should be
the action being performed on the items without specifying the
object type, i.e. "Updating" but not "Updating items".
query: The query to filter the items, equivalent to
:meth:`Library.items`'s `query` argument.
sort: The sort to apply to the items, equivalent to
:meth:`Library.items`'s `sort` argument.
unit: The unit of the progress bar, defaults to "item".
Should be singular, i.e. "item" but not "items".
"""
yield from ui.iprogress_bar(
self.items(query, sort),
desc=desc,
unit=unit,
)

def albums_with_progress(
self,
desc: str,
query=None,
sort=None,
unit: str = "album",
) -> Iterator[Album]:
"""Iterate over albums while displaying a progress bar.

Args:
desc: The description of the progress bar. Should be the action
being performed on the albums without specifying the object
type, i.e. "Updating" but not "Updating albums".
query: The query to filter the albums, equivalent to
:meth:`Library.albums`'s `query` argument.
sort: The sort to apply to the albums, equivalent to
:meth:`Library.albums`'s `sort` argument.
unit: The unit of the progress bar, defaults to "album". Should be
singular, i.e. "album" but not "albums".
"""
yield from ui.iprogress_bar(
self.albums(query, sort),
desc=desc,
unit=unit,
)

# Convenience accessors.
def get_item(self, id_: int) -> Item | None:
"""Fetch a :class:`Item` by its ID.
Expand Down
89 changes: 86 additions & 3 deletions beets/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,15 @@
import sys
import textwrap
import traceback
import typing
from collections.abc import Callable, Iterable, Sized
from difflib import SequenceMatcher
from functools import cache
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any, Literal, TypeVar

import confuse
import enlighten
from typing_extensions import Protocol

from beets import config, library, logging, plugins, util
from beets.dbcore import db
Expand All @@ -42,13 +46,14 @@
from beets.util.functemplate import template

if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterator

from beets.dbcore.db import FormattedMapping

is_windows = sys.platform == "win32"

# On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == "win32":
if is_windows:
try:
import colorama
except ImportError:
Expand Down Expand Up @@ -1245,6 +1250,84 @@ def add_all_common_options(self):
self.add_format_option()


T_co = TypeVar("T_co", covariant=True)
U = TypeVar("U")


class SizedIterable(Protocol[T_co], Sized, Iterable[T_co]):
pass


def iprogress_bar(
sequence: Iterable[U] | SizedIterable[U], **kwargs
) -> Iterator[U]:
"""Construct and manage an `enlighten.Counter` progress bar while iterating.

Example usage:
```
for album in ui.iprogress_bar(
lib.albums(),
desc="Updating albums",
unit="album",
):
do_something_to(album)
```

If the progress bar is iterating over an Album or an Item, then it will detect
whether or not the item has been modified, and will color-code the progress bar
with white and blue to indicate total progress and the portion of items that have
been modified.

Progress bars are disabled in Windows environments and when not attached to a TTY.

The progress bar's description should be the action being performed without
specifying the object type, i.e. "Updating" but not "Updating items". The unit
should be singular, i.e. "item" but not "items".

Args:
sequence: An `Iterable` sequence to iterate over. If provided, and the
sequence can return its length, then the length will be used as the
total for the counter. The counter will be updated for each item
in the sequence.
kwargs: Additional keyword arguments to pass to the `enlighten.Counter`
constructor.

Yields:
The items from the sequence.
"""
# If the total was not directly set, and the iterable is sized, then use its size as
# the progress bar's total.
if "total" not in kwargs:
if hasattr(sequence, "__len__"):
sized_seq = typing.cast(SizedIterable[U], sequence)
kwargs["total"] = len(sized_seq)

# Disabled in windows environments and when not attached to a TTY. See method docs
# for details.
with enlighten.Manager(
enabled=not is_windows
and (hasattr(sys.stdout, "isatty") and sys.stdout.isatty())
) as manager:
with manager.counter(**kwargs) as counter:
change_counter = counter.add_subcounter("blue")

for item in sequence:
revision = None
if hasattr(item, "_revision"):
revision = item._revision

# Yield the item, allowing it to be modified, or not.
yield item

if (
revision is not None
and hasattr(item, "_revision")
and item._revision != revision
):
change_counter.update()
counter.update()


# Subcommand parsing infrastructure.
#
# This is a fairly generic subcommand parser for optparse. It is
Expand Down
2 changes: 1 addition & 1 deletion beets/ui/commands/modify.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit):

# Apply changes to database and files
with lib.transaction():
for obj in changed:
for obj in ui.iprogress_bar(changed, desc="Modifying", unit="item"):
obj.try_sync(write, move, inherit)


Expand Down
9 changes: 6 additions & 3 deletions beets/ui/commands/move.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def move_items(
items, albums = do_query(lib, query, album, False)
objs = albums if album else items
num_objs = len(objs)
entity = "album" if album else "item"

# Filter out files that don't need to be moved.
def isitemmoved(item):
Expand All @@ -84,7 +85,10 @@ def isitemmoved(item):
def isalbummoved(album):
return any(isitemmoved(i) for i in album.items())

objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)]
objs_progress = ui.iprogress_bar(objs, desc="Preparing", unit=entity)
objs = [
o for o in objs_progress if (isalbummoved if album else isitemmoved)(o)
]
num_unmoved = num_objs - len(objs)
# Report unmoved files that match the query.
unmoved_msg = ""
Expand All @@ -94,7 +98,6 @@ def isalbummoved(album):
copy = copy or export # Exporting always copies.
action = "Copying" if copy else "Moving"
act = "copy" if copy else "move"
entity = "album" if album else "item"
log.info(
"{} {} {}{}{}.",
action,
Expand Down Expand Up @@ -129,7 +132,7 @@ def isalbummoved(album):
),
)

for obj in objs:
for obj in ui.iprogress_bar(objs, desc=action, unit=entity):
log.debug("moving: {.filepath}", obj)

if export:
Expand Down
2 changes: 1 addition & 1 deletion beets/ui/commands/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def fmt_album(a):

# Remove (and possibly delete) items.
with lib.transaction():
for obj in objs:
for obj in ui.iprogress_bar(objs, desc="Removing", unit="item"):
obj.remove(delete)


Expand Down
8 changes: 6 additions & 2 deletions beets/ui/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None):

# Walk through the items and pick up their changes.
affected_albums = set()
for item in items:
for item in ui.iprogress_bar(items, desc="Updating", unit="item"):
# Item deleted?
if not item.path or not os.path.exists(syspath(item.path)):
ui.print_(format(item))
Expand Down Expand Up @@ -102,7 +102,11 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None):
return

# Modify affected albums to reflect changes in their items.
for album_id in affected_albums:
for album_id in ui.iprogress_bar(
affected_albums,
desc="Updating",
unit="album",
):
if album_id is None: # Singletons.
continue
album = lib.get_album(album_id)
Expand Down
2 changes: 1 addition & 1 deletion beets/ui/commands/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def write_items(lib, query, pretend, force):
"""
items, _ = do_query(lib, query, False, False)

for item in items:
for item in ui.iprogress_bar(items, desc="Writing", unit="item"):
# Item deleted?
if not os.path.exists(syspath(item.path)):
log.info("missing file: {.filepath}", item)
Expand Down
4 changes: 2 additions & 2 deletions beetsplug/autobpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import numpy as np

from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, should_write
from beets.ui import Subcommand, iprogress_bar, should_write

if TYPE_CHECKING:
from beets.importer import ImportTask
Expand Down Expand Up @@ -56,7 +56,7 @@ def imported(self, _, task: ImportTask) -> None:
self.calculate_bpm(task.imported_items())

def calculate_bpm(self, items: list[Item], write: bool = False) -> None:
for item in items:
for item in iprogress_bar(items, desc="Calculating BPM", unit="item"):
path = item.filepath
if bpm := item.bpm:
self._log.info("BPM for {} already exists: {}", path, bpm)
Expand Down
12 changes: 10 additions & 2 deletions beetsplug/badfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

"""Use command-line tools to check for audio file corruption."""

import concurrent.futures
import errno
import os
import shlex
Expand All @@ -25,7 +26,7 @@
from beets import importer, ui
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.util import displayable_path, par_map
from beets.util import displayable_path


class CheckerCommandError(Exception):
Expand Down Expand Up @@ -203,7 +204,14 @@ def check_and_print(item):
for error_line in self.check_item(item):
ui.print_(error_line)

par_map(check_and_print, items)
with concurrent.futures.ThreadPoolExecutor() as executor:
for _ in ui.iprogress_bar(
executor.map(check_and_print, items),
desc="Checking",
unit="item",
total=len(items),
):
pass

def commands(self):
bad_command = Subcommand(
Expand Down
2 changes: 1 addition & 1 deletion beetsplug/chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ def submit_cmd_func(lib, opts, args):
)

def fingerprint_cmd_func(lib, opts, args):
for item in lib.items(args):
for item in lib.items_with_progress("Fingerprinting", args):
fingerprint_item(self._log, item, write=ui.should_write())

fingerprint_cmd.func = fingerprint_cmd_func
Expand Down
8 changes: 7 additions & 1 deletion beetsplug/duplicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import os
import shlex

from beets import ui
from beets.library import Album, Item
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, UserError, print_
Expand Down Expand Up @@ -283,8 +284,13 @@ def _group_by(self, objs, keys, strict):
"""
import collections

unit = "album" if objs and isinstance(objs[0], Album) else "item"
counts = collections.defaultdict(list)
for obj in objs:
for obj in ui.iprogress_bar(
objs,
desc="Finding duplicates",
unit=unit,
):
values = [getattr(obj, k, None) for k in keys]
values = [v for v in values if v not in (None, "")]
if strict and len(values) < len(keys):
Expand Down
Loading
Loading