Repository: beetbox/beets

Issue:
Update to use f-strings instead of `.format` now that Python 3.8 is supported
As stated in #5283 f-strings are now supported. These should be upgraded through the modules now that Python 3.8 is our minimum supported version. Probably needs to be done by hand to keep it understandable; not every instance should be changed.


Discussion:
More info: https://www.geeksforgeeks.org/formatted-string-literals-f-strings-python/
I'll pick this up.



Patch:
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index c6ec4cb5f1..b20434e23c 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -57,3 +57,13 @@ c490ac5810b70f3cf5fd8649669838e8fdb19f4d
 769dcdc88a1263638ae25944ba6b2be3e8933666
 # Reformat all docs using docstrfmt
 ab5acaabb3cd24c482adb7fa4800c89fd6a2f08d
+# Replace format calls with f-strings
+4a361bd501e85de12c91c2474c423559ca672852
+# Replace percent formatting
+9352a79e4108bd67f7e40b1e944c01e0a7353272
+# Replace string concatenation (' + ')
+1c16b2b3087e9c3635d68d41c9541c4319d0bdbe
+# Do not use backslashes to deal with long strings
+2fccf64efe82851861e195b521b14680b480a42a
+# Do not use explicit indices for logging args when not needed
+d93ddf8dd43e4f9ed072a03829e287c78d2570a2
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 92375b465c..031e8fbc5a 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -238,25 +238,22 @@ There are a few coding conventions we use in beets:
   .. code-block:: python
 
       with g.lib.transaction() as tx:
-          rows = tx.query(
-              "SELECT DISTINCT '{0}' FROM '{1}' ORDER BY '{2}'".format(
-                  field, model._table, sort_field
-              )
-          )
+          rows = tx.query("SELECT DISTINCT {field} FROM {model._table} ORDER BY {sort_field}")
 
   To fetch Item objects from the database, use lib.items(…) and supply a query
   as an argument. Resist the urge to write raw SQL for your query. If you must
-  use lower-level queries into the database, do this:
+  use lower-level queries into the database, do this, for example:
 
   .. code-block:: python
 
       with lib.transaction() as tx:
-          rows = tx.query("SELECT …")
+          rows = tx.query("SELECT path FROM items WHERE album_id = ?", (album_id,))
 
   Transaction objects help control concurrent access to the database and assist
   in debugging conflicting accesses.
 
-- ``str.format()`` should be used instead of the ``%`` operator
+- f-strings should be used instead of the ``%`` operator and ``str.format()``
+  calls.
 - Never ``print`` informational messages; use the `logging
   <http://docs.python.org/library/logging.html>`__ module instead. In
   particular, we have our own logging shim, so you’ll see ``from beets import
@@ -264,7 +261,7 @@ There are a few coding conventions we use in beets:
 
   - The loggers use `str.format
     <http://docs.python.org/library/stdtypes.html#str.format>`__-style logging
-    instead of ``%``-style, so you can type ``log.debug("{0}", obj)`` to do your
+    instead of ``%``-style, so you can type ``log.debug("{}", obj)`` to do your
     formatting.
 
 - Exception handlers must use ``except A as B:`` instead of ``except A, B:``.
diff --git a/beets/__init__.py b/beets/__init__.py
index 8be3052029..c5b93230f8 100644
--- a/beets/__init__.py
+++ b/beets/__init__.py
@@ -35,7 +35,7 @@ def read(self, user=True, defaults=True):
         except confuse.NotFoundError:
             pass
         except confuse.ConfigReadError as err:
-            stderr.write("configuration `import` failed: {}".format(err.reason))
+            stderr.write(f"configuration `import` failed: {err.reason}")
 
 
 config = IncludeLazyConfig("beets", __name__)
diff --git a/beets/art.py b/beets/art.py
index 2ff58c309e..656c303ce5 100644
--- a/beets/art.py
+++ b/beets/art.py
@@ -38,11 +38,7 @@ def get_art(log, item):
     try:
         mf = mediafile.MediaFile(syspath(item.path))
     except mediafile.UnreadableFileError as exc:
-        log.warning(
-            "Could not extract art from {0}: {1}",
-            displayable_path(item.path),
-            exc,
-        )
+        log.warning("Could not extract art from {.filepath}: {}", item, exc)
         return
 
     return mf.art
@@ -83,16 +79,16 @@ def embed_item(
 
     # Get the `Image` object from the file.
     try:
-        log.debug("embedding {0}", displayable_path(imagepath))
+        log.debug("embedding {}", displayable_path(imagepath))
         image = mediafile_image(imagepath, maxwidth)
     except OSError as exc:
-        log.warning("could not read image file: {0}", exc)
+        log.warning("could not read image file: {}", exc)
         return
 
     # Make sure the image kind is safe (some formats only support PNG
     # and JPEG).
     if image.mime_type not in ("image/jpeg", "image/png"):
-        log.info("not embedding image of unsupported type: {}", image.mime_type)
+        log.info("not embedding image of unsupported type: {.mime_type}", image)
         return
 
     item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23)
@@ -110,11 +106,11 @@ def embed_album(
     """Embed album art into all of the album's items."""
     imagepath = album.artpath
     if not imagepath:
-        log.info("No album art present for {0}", album)
+        log.info("No album art present for {}", album)
         return
     if not os.path.isfile(syspath(imagepath)):
         log.info(
-            "Album art not found at {0} for {1}",
+            "Album art not found at {} for {}",
             displayable_path(imagepath),
             album,
         )
@@ -122,7 +118,7 @@ def embed_album(
     if maxwidth:
         imagepath = resize_image(log, imagepath, maxwidth, quality)
 
-    log.info("Embedding album art into {0}", album)
+    log.info("Embedding album art into {}", album)
 
     for item in album.items():
         embed_item(
@@ -143,8 +139,7 @@ def resize_image(log, imagepath, maxwidth, quality):
     specified quality level.
     """
     log.debug(
-        "Resizing album art to {0} pixels wide and encoding at quality \
-              level {1}",
+        "Resizing album art to {} pixels wide and encoding at quality level {}",
         maxwidth,
         quality,
     )
@@ -184,18 +179,18 @@ def extract(log, outpath, item):
     art = get_art(log, item)
     outpath = bytestring_path(outpath)
     if not art:
-        log.info("No album art present in {0}, skipping.", item)
+        log.info("No album art present in {}, skipping.", item)
         return
 
     # Add an extension to the filename.
     ext = mediafile.image_extension(art)
     if not ext:
-        log.warning("Unknown image type in {0}.", displayable_path(item.path))
+        log.warning("Unknown image type in {.filepath}.", item)
         return
-    outpath += bytestring_path("." + ext)
+    outpath += bytestring_path(f".{ext}")
 
     log.info(
-        "Extracting album art from: {0} to: {1}",
+        "Extracting album art from: {} to: {}",
         item,
         displayable_path(outpath),
     )
@@ -213,7 +208,7 @@ def extract_first(log, outpath, items):
 
 def clear(log, lib, query):
     items = lib.items(query)
-    log.info("Clearing album art from {0} items", len(items))
+    log.info("Clearing album art from {} items", len(items))
     for item in items:
-        log.debug("Clearing art for {0}", item)
+        log.debug("Clearing art for {}", item)
         item.try_write(tags={"images": None})
diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py
index 4d107b3a1c..319f7f5226 100644
--- a/beets/autotag/__init__.py
+++ b/beets/autotag/__init__.py
@@ -261,7 +261,7 @@ def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
                 continue
 
             for suffix in "year", "month", "day":
-                key = prefix + suffix
+                key = f"{prefix}{suffix}"
                 value = getattr(album_info, key) or 0
 
                 # If we don't even have a year, apply nothing.
diff --git a/beets/autotag/distance.py b/beets/autotag/distance.py
index 39d16858f4..727439ea3a 100644
--- a/beets/autotag/distance.py
+++ b/beets/autotag/distance.py
@@ -78,10 +78,10 @@ def string_dist(str1: str | None, str2: str | None) -> float:
     # example, "the something" should be considered equal to
     # "something, the".
     for word in SD_END_WORDS:
-        if str1.endswith(", %s" % word):
-            str1 = "{} {}".format(word, str1[: -len(word) - 2])
-        if str2.endswith(", %s" % word):
-            str2 = "{} {}".format(word, str2[: -len(word) - 2])
+        if str1.endswith(f", {word}"):
+            str1 = f"{word} {str1[: -len(word) - 2]}"
+        if str2.endswith(f", {word}"):
+            str2 = f"{word} {str2[: -len(word) - 2]}"
 
     # Perform a couple of basic normalizing substitutions.
     for pat, repl in SD_REPLACE:
@@ -230,7 +230,7 @@ def update(self, dist: Distance):
         """Adds all the distance penalties from `dist`."""
         if not isinstance(dist, Distance):
             raise ValueError(
-                "`dist` must be a Distance object, not {}".format(type(dist))
+                f"`dist` must be a Distance object, not {type(dist)}"
             )
         for key, penalties in dist._penalties.items():
             self._penalties.setdefault(key, []).extend(penalties)
@@ -444,7 +444,7 @@ def distance(
         # Preferred media options.
         media_patterns: Sequence[str] = preferred_config["media"].as_str_seq()
         options = [
-            re.compile(r"(\d+x)?(%s)" % pat, re.I) for pat in media_patterns
+            re.compile(rf"(\d+x)?({pat})", re.I) for pat in media_patterns
         ]
         if options:
             dist.add_priority("media", album_info.media, options)
diff --git a/beets/autotag/match.py b/beets/autotag/match.py
index e74d217551..8fec844a64 100644
--- a/beets/autotag/match.py
+++ b/beets/autotag/match.py
@@ -118,7 +118,7 @@ def match_by_id(items: Iterable[Item]) -> AlbumInfo | None:
             log.debug("No album ID consensus.")
             return None
     # If all album IDs are equal, look up the album.
-    log.debug("Searching for discovered album ID: {0}", first)
+    log.debug("Searching for discovered album ID: {}", first)
     return metadata_plugins.album_for_id(first)
 
 
@@ -197,9 +197,7 @@ def _add_candidate(
     checking the track count, ordering the items, checking for
     duplicates, and calculating the distance.
     """
-    log.debug(
-        "Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id
-    )
+    log.debug("Candidate: {0.artist} - {0.album} ({0.album_id})", info)
 
     # Discard albums with zero tracks.
     if not info.tracks:
@@ -215,7 +213,7 @@ def _add_candidate(
     required_tags: Sequence[str] = config["match"]["required"].as_str_seq()
     for req_tag in required_tags:
         if getattr(info, req_tag) is None:
-            log.debug("Ignored. Missing required tag: {0}", req_tag)
+            log.debug("Ignored. Missing required tag: {}", req_tag)
             return
 
     # Find mapping between the items and the track info.
@@ -229,10 +227,10 @@ def _add_candidate(
     ignored_tags: Sequence[str] = config["match"]["ignored"].as_str_seq()
     for penalty in ignored_tags:
         if penalty in penalties:
-            log.debug("Ignored. Penalty: {0}", penalty)
+            log.debug("Ignored. Penalty: {}", penalty)
             return
 
-    log.debug("Success. Distance: {0}", dist)
+    log.debug("Success. Distance: {}", dist)
     results[info.album_id] = hooks.AlbumMatch(
         dist, info, mapping, extra_items, extra_tracks
     )
@@ -265,7 +263,7 @@ def tag_album(
     likelies, consensus = get_most_common_tags(items)
     cur_artist: str = likelies["artist"]
     cur_album: str = likelies["album"]
-    log.debug("Tagging {0} - {1}", cur_artist, cur_album)
+    log.debug("Tagging {} - {}", cur_artist, cur_album)
 
     # The output result, keys are the MB album ID.
     candidates: dict[Any, AlbumMatch] = {}
@@ -273,7 +271,7 @@ def tag_album(
     # Search by explicit ID.
     if search_ids:
         for search_id in search_ids:
-            log.debug("Searching for album ID: {0}", search_id)
+            log.debug("Searching for album ID: {}", search_id)
             if info := metadata_plugins.album_for_id(search_id):
                 _add_candidate(items, candidates, info)
 
@@ -283,7 +281,7 @@ def tag_album(
         if info := match_by_id(items):
             _add_candidate(items, candidates, info)
             rec = _recommendation(list(candidates.values()))
-            log.debug("Album ID match recommendation is {0}", rec)
+            log.debug("Album ID match recommendation is {}", rec)
             if candidates and not config["import"]["timid"]:
                 # If we have a very good MBID match, return immediately.
                 # Otherwise, this match will compete against metadata-based
@@ -300,7 +298,7 @@ def tag_album(
         if not (search_artist and search_album):
             # No explicit search terms -- use current metadata.
             search_artist, search_album = cur_artist, cur_album
-        log.debug("Search terms: {0} - {1}", search_artist, search_album)
+        log.debug("Search terms: {} - {}", search_artist, search_album)
 
         # Is this album likely to be a "various artist" release?
         va_likely = (
@@ -308,7 +306,7 @@ def tag_album(
             or (search_artist.lower() in VA_ARTISTS)
             or any(item.comp for item in items)
         )
-        log.debug("Album might be VA: {0}", va_likely)
+        log.debug("Album might be VA: {}", va_likely)
 
         # Get the results from the data sources.
         for matched_candidate in metadata_plugins.candidates(
@@ -316,7 +314,7 @@ def tag_album(
         ):
             _add_candidate(items, candidates, matched_candidate)
 
-    log.debug("Evaluating {0} candidates.", len(candidates))
+    log.debug("Evaluating {} candidates.", len(candidates))
     # Sort and get the recommendation.
     candidates_sorted = _sort_candidates(candidates.values())
     rec = _recommendation(candidates_sorted)
@@ -345,7 +343,7 @@ def tag_item(
     trackids = search_ids or [t for t in [item.mb_trackid] if t]
     if trackids:
         for trackid in trackids:
-            log.debug("Searching for track ID: {0}", trackid)
+            log.debug("Searching for track ID: {}", trackid)
             if info := metadata_plugins.track_for_id(trackid):
                 dist = track_distance(item, info, incl_artist=True)
                 candidates[info.track_id] = hooks.TrackMatch(dist, info)
@@ -369,7 +367,7 @@ def tag_item(
     # Search terms.
     search_artist = search_artist or item.artist
     search_title = search_title or item.title
-    log.debug("Item search terms: {0} - {1}", search_artist, search_title)
+    log.debug("Item search terms: {} - {}", search_artist, search_title)
 
     # Get and evaluate candidate metadata.
     for track_info in metadata_plugins.item_candidates(
@@ -379,7 +377,7 @@ def tag_item(
         candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
 
     # Sort by distance and return with recommendation.
-    log.debug("Found {0} candidates.", len(candidates))
+    log.debug("Found {} candidates.", len(candidates))
     candidates_sorted = _sort_candidates(candidates.values())
     rec = _recommendation(candidates_sorted)
     return Proposal(candidates_sorted, rec)
diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py
index 81c1be4b90..8cd89111ee 100755
--- a/beets/dbcore/db.py
+++ b/beets/dbcore/db.py
@@ -390,9 +390,9 @@ def _awaken(
         return obj
 
     def __repr__(self) -> str:
-        return "{}({})".format(
-            type(self).__name__,
-            ", ".join(f"{k}={v!r}" for k, v in dict(self).items()),
+        return (
+            f"{type(self).__name__}"
+            f"({', '.join(f'{k}={v!r}' for k, v in dict(self).items())})"
         )
 
     def clear_dirty(self):
@@ -409,9 +409,9 @@ def _check_db(self, need_id: bool = True) -> D:
         exception is raised otherwise.
         """
         if not self._db:
-            raise ValueError("{} has no database".format(type(self).__name__))
+            raise ValueError(f"{type(self).__name__} has no database")
         if need_id and not self.id:
-            raise ValueError("{} has no id".format(type(self).__name__))
+            raise ValueError(f"{type(self).__name__} has no id")
 
         return self._db
 
@@ -588,16 +588,14 @@ def store(self, fields: Iterable[str] | None = None):
         for key in fields:
             if key != "id" and key in self._dirty:
                 self._dirty.remove(key)
-                assignments.append(key + "=?")
+                assignments.append(f"{key}=?")
                 value = self._type(key).to_sql(self[key])
                 subvars.append(value)
 
         with db.transaction() as tx:
             # Main table update.
             if assignments:
-                query = "UPDATE {} SET {} WHERE id=?".format(
-                    self._table, ",".join(assignments)
-                )
+                query = f"UPDATE {self._table} SET {','.join(assignments)} WHERE id=?"
                 subvars.append(self.id)
                 tx.mutate(query, subvars)
 
@@ -607,9 +605,9 @@ def store(self, fields: Iterable[str] | None = None):
                     self._dirty.remove(key)
                     value = self._type(key).to_sql(value)
                     tx.mutate(
-                        "INSERT INTO {} "
+                        f"INSERT INTO {self._flex_table} "
                         "(entity_id, key, value) "
-                        "VALUES (?, ?, ?);".format(self._flex_table),
+                        "VALUES (?, ?, ?);",
                         (self.id, key, value),
                     )
 
@@ -1160,7 +1158,7 @@ def _make_table(self, table: str, fields: Mapping[str, types.Type]):
         """
         # Get current schema.
         with self.transaction() as tx:
-            rows = tx.query("PRAGMA table_info(%s)" % table)
+            rows = tx.query(f"PRAGMA table_info({table})")
         current_fields = {row[1] for row in rows}
 
         field_names = set(fields.keys())
@@ -1173,9 +1171,7 @@ def _make_table(self, table: str, fields: Mapping[str, types.Type]):
             columns = []
             for name, typ in fields.items():
                 columns.append(f"{name} {typ.sql}")
-            setup_sql = "CREATE TABLE {} ({});\n".format(
-                table, ", ".join(columns)
-            )
+            setup_sql = f"CREATE TABLE {table} ({', '.join(columns)});\n"
 
         else:
             # Table exists does not match the field set.
@@ -1183,8 +1179,8 @@ def _make_table(self, table: str, fields: Mapping[str, types.Type]):
             for name, typ in fields.items():
                 if name in current_fields:
                     continue
-                setup_sql += "ALTER TABLE {} ADD COLUMN {} {};\n".format(
-                    table, name, typ.sql
+                setup_sql += (
+                    f"ALTER TABLE {table} ADD COLUMN {name} {typ.sql};\n"
                 )
 
         with self.transaction() as tx:
@@ -1195,18 +1191,16 @@ def _make_attribute_table(self, flex_table: str):
         for the given entity (if they don't exist).
         """
         with self.transaction() as tx:
-            tx.script(
-                """
-                CREATE TABLE IF NOT EXISTS {0} (
+            tx.script(f"""
+                CREATE TABLE IF NOT EXISTS {flex_table} (
                     id INTEGER PRIMARY KEY,
                     entity_id INTEGER,
                     key TEXT,
                     value TEXT,
                     UNIQUE(entity_id, key) ON CONFLICT REPLACE);
-                CREATE INDEX IF NOT EXISTS {0}_by_entity
-                    ON {0} (entity_id);
-                """.format(flex_table)
-            )
+                CREATE INDEX IF NOT EXISTS {flex_table}_by_entity
+                    ON {flex_table} (entity_id);
+                """)
 
     # Querying.
 
diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py
index 49d7f64283..dfeb427078 100644
--- a/beets/dbcore/query.py
+++ b/beets/dbcore/query.py
@@ -190,7 +190,7 @@ class MatchQuery(FieldQuery[AnySQLiteType]):
     """A query that looks for exact matches in an Model field."""
 
     def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
-        return self.field + " = ?", [self.pattern]
+        return f"{self.field} = ?", [self.pattern]
 
     @classmethod
     def value_match(cls, pattern: AnySQLiteType, value: Any) -> bool:
@@ -204,7 +204,7 @@ def __init__(self, field, fast: bool = True):
         super().__init__(field, None, fast)
 
     def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
-        return self.field + " IS NULL", ()
+        return f"{self.field} IS NULL", ()
 
     def match(self, obj: Model) -> bool:
         return obj.get(self.field_name) is None
@@ -246,7 +246,7 @@ def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
             .replace("%", "\\%")
             .replace("_", "\\_")
         )
-        clause = self.field + " like ? escape '\\'"
+        clause = f"{self.field} like ? escape '\\'"
         subvals = [search]
         return clause, subvals
 
@@ -264,8 +264,8 @@ def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
             .replace("%", "\\%")
             .replace("_", "\\_")
         )
-        search = "%" + pattern + "%"
-        clause = self.field + " like ? escape '\\'"
+        search = f"%{pattern}%"
+        clause = f"{self.field} like ? escape '\\'"
         subvals = [search]
         return clause, subvals
 
@@ -471,11 +471,11 @@ def match(self, obj: Model) -> bool:
 
     def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
         if self.point is not None:
-            return self.field + "=?", (self.point,)
+            return f"{self.field}=?", (self.point,)
         else:
             if self.rangemin is not None and self.rangemax is not None:
                 return (
-                    "{0} >= ? AND {0} <= ?".format(self.field),
+                    f"{self.field} >= ? AND {self.field} <= ?",
                     (self.rangemin, self.rangemax),
                 )
             elif self.rangemin is not None:
@@ -549,9 +549,9 @@ def clause_with_joiner(
             if not subq_clause:
                 # Fall back to slow query.
                 return None, ()
-            clause_parts.append("(" + subq_clause + ")")
+            clause_parts.append(f"({subq_clause})")
             subvals += subq_subvals
-        clause = (" " + joiner + " ").join(clause_parts)
+        clause = f" {joiner} ".join(clause_parts)
         return clause, subvals
 
     def __repr__(self) -> str:
@@ -690,9 +690,7 @@ class Period:
         ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"),  # second
     )
     relative_units = {"y": 365, "m": 30, "w": 7, "d": 1}
-    relative_re = (
-        "(?P<sign>[+|-]?)(?P<quantity>[0-9]+)" + "(?P<timespan>[y|m|w|d])"
-    )
+    relative_re = "(?P<sign>[+|-]?)(?P<quantity>[0-9]+)(?P<timespan>[y|m|w|d])"
 
     def __init__(self, date: datetime, precision: str):
         """Create a period with the given date (a `datetime` object) and
@@ -800,9 +798,7 @@ class DateInterval:
 
     def __init__(self, start: datetime | None, end: datetime | None):
         if start is not None and end is not None and not start < end:
-            raise ValueError(
-                "start date {} is not before end date {}".format(start, end)
-            )
+            raise ValueError(f"start date {start} is not before end date {end}")
         self.start = start
         self.end = end
 
@@ -850,8 +846,6 @@ def match(self, obj: Model) -> bool:
         date = datetime.fromtimestamp(timestamp)
         return self.interval.contains(date)
 
-    _clause_tmpl = "{0} {1} ?"
-
     def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
         clause_parts = []
         subvals = []
@@ -859,11 +853,11 @@ def col_clause(self) -> tuple[str, Sequence[SQLiteType]]:
         # Convert the `datetime` objects to an integer number of seconds since
         # the (local) Unix epoch using `datetime.timestamp()`.
         if self.interval.start:
-            clause_parts.append(self._clause_tmpl.format(self.field, ">="))
+            clause_parts.append(f"{self.field} >= ?")
             subvals.append(int(self.interval.start.timestamp()))
 
         if self.interval.end:
-            clause_parts.append(self._clause_tmpl.format(self.field, "<"))
+            clause_parts.append(f"{self.field} < ?")
             subvals.append(int(self.interval.end.timestamp()))
 
         if clause_parts:
@@ -1074,9 +1068,9 @@ def order_clause(self) -> str:
         if self.case_insensitive:
             field = (
                 "(CASE "
-                "WHEN TYPEOF({0})='text' THEN LOWER({0}) "
-                "WHEN TYPEOF({0})='blob' THEN LOWER({0}) "
-                "ELSE {0} END)".format(self.field)
+                f"WHEN TYPEOF({self.field})='text' THEN LOWER({self.field}) "
+                f"WHEN TYPEOF({self.field})='blob' THEN LOWER({self.field}) "
+                f"ELSE {self.field} END)"
             )
         else:
             field = self.field
diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py
index 1b8434a0ba..3b4badd33c 100644
--- a/beets/dbcore/types.py
+++ b/beets/dbcore/types.py
@@ -194,7 +194,7 @@ def __init__(self, digits: int):
         self.digits = digits
 
     def format(self, value: int | N) -> str:
-        return "{0:0{1}d}".format(value or 0, self.digits)
+        return f"{value or 0:0{self.digits}d}"
 
 
 class PaddedInt(BasePaddedInt[int]):
@@ -219,7 +219,7 @@ def __init__(self, unit: int, suffix: str = ""):
         self.suffix = suffix
 
     def format(self, value: int) -> str:
-        return "{}{}".format((value or 0) // self.unit, self.suffix)
+        return f"{(value or 0) // self.unit}{self.suffix}"
 
 
 class Id(NullInteger):
@@ -249,7 +249,7 @@ def __init__(self, digits: int = 1):
         self.digits = digits
 
     def format(self, value: float | N) -> str:
-        return "{0:.{1}f}".format(value or 0, self.digits)
+        return f"{value or 0:.{self.digits}f}"
 
 
 class Float(BaseFloat[float]):
diff --git a/beets/importer/session.py b/beets/importer/session.py
index e45644fa33..46277837e8 100644
--- a/beets/importer/session.py
+++ b/beets/importer/session.py
@@ -150,7 +150,7 @@ def tag_log(self, status, paths: Sequence[PathBytes]):
         """Log a message about a given album to the importer log. The status
         should reflect the reason the album couldn't be tagged.
         """
-        self.logger.info("{0} {1}", status, displayable_path(paths))
+        self.logger.info("{} {}", status, displayable_path(paths))
 
     def log_choice(self, task: ImportTask, duplicate=False):
         """Logs the task's current choice if it should be logged. If
@@ -187,7 +187,7 @@ def choose_item(self, task: ImportTask):
 
     def run(self):
         """Run the import task."""
-        self.logger.info("import started {0}", time.asctime())
+        self.logger.info("import started {}", time.asctime())
         self.set_config(config["import"])
 
         # Set up the pipeline.
@@ -297,7 +297,7 @@ def ask_resume(self, toppath: PathBytes):
             # Either accept immediately or prompt for input to decide.
             if self.want_resume is True or self.should_resume(toppath):
                 log.warning(
-                    "Resuming interrupted import of {0}",
+                    "Resuming interrupted import of {}",
                     util.displayable_path(toppath),
                 )
                 self._is_resuming[toppath] = True
diff --git a/beets/importer/stages.py b/beets/importer/stages.py
index 24ff815f3a..d99b742a2b 100644
--- a/beets/importer/stages.py
+++ b/beets/importer/stages.py
@@ -58,11 +58,11 @@ def read_tasks(session: ImportSession):
         skipped += task_factory.skipped
 
         if not task_factory.imported:
-            log.warning("No files imported from {0}", displayable_path(toppath))
+            log.warning("No files imported from {}", displayable_path(toppath))
 
     # Show skipped directories (due to incremental/resume).
     if skipped:
-        log.info("Skipped {0} paths.", skipped)
+        log.info("Skipped {} paths.", skipped)
 
 
 def query_tasks(session: ImportSession):
@@ -82,10 +82,7 @@ def query_tasks(session: ImportSession):
         # Search for albums.
         for album in session.lib.albums(session.query):
             log.debug(
-                "yielding album {0}: {1} - {2}",
-                album.id,
-                album.albumartist,
-                album.album,
+                "yielding album {0.id}: {0.albumartist} - {0.album}", album
             )
             items = list(album.items())
             _freshen_items(items)
@@ -140,7 +137,7 @@ def lookup_candidates(session: ImportSession, task: ImportTask):
         return
 
     plugins.send("import_task_start", session=session, task=task)
-    log.debug("Looking up: {0}", displayable_path(task.paths))
+    log.debug("Looking up: {}", displayable_path(task.paths))
 
     # Restrict the initial lookup to IDs specified by the user via the -m
     # option. Currently all the IDs are passed onto the tasks directly.
@@ -259,11 +256,11 @@ def plugin_stage(
 def log_files(session: ImportSession, task: ImportTask):
     """A coroutine (pipeline stage) to log each file to be imported."""
     if isinstance(task, SingletonImportTask):
-        log.info("Singleton: {0}", displayable_path(task.item["path"]))
+        log.info("Singleton: {}", displayable_path(task.item["path"]))
     elif task.items:
-        log.info("Album: {0}", displayable_path(task.paths[0]))
+        log.info("Album: {}", displayable_path(task.paths[0]))
         for item in task.items:
-            log.info("  {0}", displayable_path(item["path"]))
+            log.info("  {}", displayable_path(item["path"]))
 
 
 # --------------------------------- Consumer --------------------------------- #
@@ -341,9 +338,7 @@ def _resolve_duplicates(session: ImportSession, task: ImportTask):
     if task.choice_flag in (Action.ASIS, Action.APPLY, Action.RETAG):
         found_duplicates = task.find_duplicates(session.lib)
         if found_duplicates:
-            log.debug(
-                "found duplicates: {}".format([o.id for o in found_duplicates])
-            )
+            log.debug("found duplicates: {}", [o.id for o in found_duplicates])
 
             # Get the default action to follow from config.
             duplicate_action = config["import"]["duplicate_action"].as_choice(
@@ -355,7 +350,7 @@ def _resolve_duplicates(session: ImportSession, task: ImportTask):
                     "ask": "a",
                 }
             )
-            log.debug("default action for duplicates: {0}", duplicate_action)
+            log.debug("default action for duplicates: {}", duplicate_action)
 
             if duplicate_action == "s":
                 # Skip new.
diff --git a/beets/importer/state.py b/beets/importer/state.py
index fccb7c282e..fde26c6063 100644
--- a/beets/importer/state.py
+++ b/beets/importer/state.py
@@ -87,7 +87,7 @@ def _open(
             # unpickling, including ImportError. We use a catch-all
             # exception to avoid enumerating them all (the docs don't even have a
             # full list!).
-            log.debug("state file could not be read: {0}", exc)
+            log.debug("state file could not be read: {}", exc)
 
     def _save(self):
         try:
@@ -100,7 +100,7 @@ def _save(self):
                     f,
                 )
         except OSError as exc:
-            log.error("state file could not be written: {0}", exc)
+            log.error("state file could not be written: {}", exc)
 
     # -------------------------------- Tagprogress ------------------------------- #
 
diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py
index abe2ca8a96..b4d566032c 100644
--- a/beets/importer/tasks.py
+++ b/beets/importer/tasks.py
@@ -267,13 +267,11 @@ def duplicate_items(self, lib: library.Library):
 
     def remove_duplicates(self, lib: library.Library):
         duplicate_items = self.duplicate_items(lib)
-        log.debug("removing {0} old duplicated items", len(duplicate_items))
+        log.debug("removing {} old duplicated items", len(duplicate_items))
         for item in duplicate_items:
             item.remove()
             if lib.directory in util.ancestry(item.path):
-                log.debug(
-                    "deleting duplicate {0}", util.displayable_path(item.path)
-                )
+                log.debug("deleting duplicate {.filepath}", item)
                 util.remove(item.path)
                 util.prune_dirs(os.path.dirname(item.path), lib.directory)
 
@@ -285,10 +283,10 @@ def set_fields(self, lib: library.Library):
         for field, view in config["import"]["set_fields"].items():
             value = str(view.get())
             log.debug(
-                "Set field {1}={2} for {0}",
-                util.displayable_path(self.paths),
+                "Set field {}={} for {}",
                 field,
                 value,
+                util.displayable_path(self.paths),
             )
             self.album.set_parse(field, format(self.album, value))
             for item in items:
@@ -554,12 +552,11 @@ def _reduce_and_log(new_obj, existing_fields, overwrite_keys):
             ]
             if overwritten_fields:
                 log.debug(
-                    "Reimported {} {}. Not preserving flexible attributes {}. "
-                    "Path: {}",
+                    "Reimported {0} {1.id}. Not preserving flexible attributes {2}. "
+                    "Path: {1.filepath}",
                     noun,
-                    new_obj.id,
+                    new_obj,
                     overwritten_fields,
-                    util.displayable_path(new_obj.path),
                 )
                 for key in overwritten_fields:
                     del existing_fields[key]
@@ -578,17 +575,15 @@ def _reduce_and_log(new_obj, existing_fields, overwrite_keys):
                 self.album.artpath = replaced_album.artpath
                 self.album.store()
                 log.debug(
-                    "Reimported album {}. Preserving attribute ['added']. "
-                    "Path: {}",
-                    self.album.id,
-                    util.displayable_path(self.album.path),
+                    "Reimported album {0.album.id}. Preserving attribute ['added']. "
+                    "Path: {0.album.filepath}",
+                    self,
                 )
                 log.debug(
-                    "Reimported album {}. Preserving flexible attributes {}. "
-                    "Path: {}",
-                    self.album.id,
+                    "Reimported album {0.album.id}. Preserving flexible"
+                    " attributes {1}. Path: {0.album.filepath}",
+                    self,
                     list(album_fields.keys()),
-                    util.displayable_path(self.album.path),
                 )
 
         for item in self.imported_items():
@@ -597,21 +592,19 @@ def _reduce_and_log(new_obj, existing_fields, overwrite_keys):
                 if dup_item.added and dup_item.added != item.added:
                     item.added = dup_item.added
                     log.debug(
-                        "Reimported item {}. Preserving attribute ['added']. "
-                        "Path: {}",
-                        item.id,
-                        util.displayable_path(item.path),
+                        "Reimported item {0.id}. Preserving attribute ['added']. "
+                        "Path: {0.filepath}",
+                        item,
                     )
                 item_fields = _reduce_and_log(
                     item, dup_item._values_flex, REIMPORT_FRESH_FIELDS_ITEM
                 )
                 item.update(item_fields)
                 log.debug(
-                    "Reimported item {}. Preserving flexible attributes {}. "
-                    "Path: {}",
-                    item.id,
+                    "Reimported item {0.id}. Preserving flexible attributes {1}. "
+                    "Path: {0.filepath}",
+                    item,
                     list(item_fields.keys()),
-                    util.displayable_path(item.path),
                 )
                 item.store()
 
@@ -621,14 +614,10 @@ def remove_replaced(self, lib):
         """
         for item in self.imported_items():
             for dup_item in self.replaced_items[item]:
-                log.debug(
-                    "Replacing item {0}: {1}",
-                    dup_item.id,
-                    util.displayable_path(item.path),
-                )
+                log.debug("Replacing item {.id}: {.filepath}", dup_item, item)
                 dup_item.remove()
         log.debug(
-            "{0} of {1} items replaced",
+            "{} of {} items replaced",
             sum(bool(v) for v in self.replaced_items.values()),
             len(self.imported_items()),
         )
@@ -747,10 +736,10 @@ def set_fields(self, lib):
         for field, view in config["import"]["set_fields"].items():
             value = str(view.get())
             log.debug(
-                "Set field {1}={2} for {0}",
-                util.displayable_path(self.paths),
+                "Set field {}={} for {}",
                 field,
                 value,
+                util.displayable_path(self.paths),
             )
             self.item.set_parse(field, format(self.item, value))
         self.item.store()
@@ -870,7 +859,7 @@ def cleanup(self, copy=False, delete=False, move=False):
         """Removes the temporary directory the archive was extracted to."""
         if self.extracted and self.toppath:
             log.debug(
-                "Removing extracted directory: {0}",
+                "Removing extracted directory: {}",
                 util.displayable_path(self.toppath),
             )
             shutil.rmtree(util.syspath(self.toppath))
@@ -1002,7 +991,7 @@ def singleton(self, path: util.PathBytes):
         """Return a `SingletonImportTask` for the music file."""
         if self.session.already_imported(self.toppath, [path]):
             log.debug(
-                "Skipping previously-imported path: {0}",
+                "Skipping previously-imported path: {}",
                 util.displayable_path(path),
             )
             self.skipped += 1
@@ -1026,7 +1015,7 @@ def album(self, paths: Iterable[util.PathBytes], dirs=None):
 
         if self.session.already_imported(self.toppath, dirs):
             log.debug(
-                "Skipping previously-imported path: {0}",
+                "Skipping previously-imported path: {}",
                 util.displayable_path(dirs),
             )
             self.skipped += 1
@@ -1063,19 +1052,17 @@ def unarchive(self):
             )
             return
 
-        log.debug(
-            "Extracting archive: {0}", util.displayable_path(self.toppath)
-        )
+        log.debug("Extracting archive: {}", util.displayable_path(self.toppath))
         archive_task = ArchiveImportTask(self.toppath)
         try:
             archive_task.extract()
         except Exception as exc:
-            log.error("extraction failed: {0}", exc)
+            log.error("extraction failed: {}", exc)
             return
 
         # Now read albums from the extracted directory.
         self.toppath = archive_task.toppath
-        log.debug("Archive extracted to: {0}", self.toppath)
+        log.debug("Archive extracted to: {.toppath}", self)
         return archive_task
 
     def read_item(self, path: util.PathBytes):
@@ -1091,10 +1078,10 @@ def read_item(self, path: util.PathBytes):
                 # Silently ignore non-music files.
                 pass
             elif isinstance(exc.reason, mediafile.UnreadableFileError):
-                log.warning("unreadable file: {0}", util.displayable_path(path))
+                log.warning("unreadable file: {}", util.displayable_path(path))
             else:
                 log.error(
-                    "error reading {0}: {1}", util.displayable_path(path), exc
+                    "error reading {}: {}", util.displayable_path(path), exc
                 )
 
 
diff --git a/beets/library/exceptions.py b/beets/library/exceptions.py
index 7f117a2fef..0dc874c2a1 100644
--- a/beets/library/exceptions.py
+++ b/beets/library/exceptions.py
@@ -28,11 +28,11 @@ class ReadError(FileOperationError):
     """An error while reading a file (i.e. in `Item.read`)."""
 
     def __str__(self):
-        return "error reading " + str(super())
+        return f"error reading {super()}"
 
 
 class WriteError(FileOperationError):
     """An error while writing a file (i.e. in `Item.write`)."""
 
     def __str__(self):
-        return "error writing " + str(super())
+        return f"error writing {super()}"
diff --git a/beets/library/models.py b/beets/library/models.py
index 7501513a1c..cbee2a411e 100644
--- a/beets/library/models.py
+++ b/beets/library/models.py
@@ -425,7 +425,7 @@ def move_art(self, operation=MoveOperation.MOVE):
 
         new_art = util.unique_path(new_art)
         log.debug(
-            "moving album art {0} to {1}",
+            "moving album art {} to {}",
             util.displayable_path(old_art),
             util.displayable_path(new_art),
         )
@@ -482,7 +482,7 @@ def item_dir(self):
         """
         item = self.items().get()
         if not item:
-            raise ValueError("empty album for album id %d" % self.id)
+            raise ValueError(f"empty album for album id {self.id}")
         return os.path.dirname(item.path)
 
     def _albumtotal(self):
@@ -844,12 +844,9 @@ def __repr__(self):
         # This must not use `with_album=True`, because that might access
         # the database. When debugging, that is not guaranteed to succeed, and
         # can even deadlock due to the database lock.
-        return "{}({})".format(
-            type(self).__name__,
-            ", ".join(
-                "{}={!r}".format(k, self[k])
-                for k in self.keys(with_album=False)
-            ),
+        return (
+            f"{type(self).__name__}"
+            f"({', '.join(f'{k}={self[k]!r}' for k in self.keys(with_album=False))})"
         )
 
     def keys(self, computed=False, with_album=True):
@@ -995,7 +992,7 @@ def try_write(self, *args, **kwargs):
             self.write(*args, **kwargs)
             return True
         except FileOperationError as exc:
-            log.error("{0}", exc)
+            log.error("{}", exc)
             return False
 
     def try_sync(self, write, move, with_album=True):
@@ -1015,10 +1012,7 @@ def try_sync(self, write, move, with_album=True):
         if move:
             # Check whether this file is inside the library directory.
             if self._db and self._db.directory in util.ancestry(self.path):
-                log.debug(
-                    "moving {0} to synchronize path",
-                    util.displayable_path(self.path),
-                )
+                log.debug("moving {.filepath} to synchronize path", self)
                 self.move(with_album=with_album)
         self.store()
 
@@ -1090,7 +1084,7 @@ def try_filesize(self):
         try:
             return os.path.getsize(syspath(self.path))
         except (OSError, Exception) as exc:
-            log.warning("could not get filesize: {0}", exc)
+            log.warning("could not get filesize: {}", exc)
             return 0
 
     # Model methods.
diff --git a/beets/plugins.py b/beets/plugins.py
index 2daede6551..d9df4323c4 100644
--- a/beets/plugins.py
+++ b/beets/plugins.py
@@ -130,9 +130,9 @@ def __init__(self, plugin):
     def filter(self, record):
         if hasattr(record.msg, "msg") and isinstance(record.msg.msg, str):
             # A _LogMessage from our hacked-up Logging replacement.
-            record.msg.msg = self.prefix + record.msg.msg
+            record.msg.msg = f"{self.prefix}{record.msg.msg}"
         elif isinstance(record.msg, str):
-            record.msg = self.prefix + record.msg
+            record.msg = f"{self.prefix}{record.msg}"
         return True
 
 
@@ -424,9 +424,9 @@ def types(model_cls: type[AnyModel]) -> dict[str, Type]:
         for field in plugin_types:
             if field in types and plugin_types[field] != types[field]:
                 raise PluginConflictError(
-                    "Plugin {} defines flexible field {} "
+                    f"Plugin {plugin.name} defines flexible field {field} "
                     "which has already been defined with "
-                    "another type.".format(plugin.name, field)
+                    "another type."
                 )
         types.update(plugin_types)
     return types
@@ -543,7 +543,7 @@ def send(event: EventType, **arguments: Any) -> list[Any]:
 
     Return a list of non-None values returned from the handlers.
     """
-    log.debug("Sending event: {0}", event)
+    log.debug("Sending event: {}", event)
     return [
         r
         for handler in BeetsPlugin.listeners[event]
@@ -560,8 +560,8 @@ def feat_tokens(for_artist: bool = True) -> str:
     feat_words = ["ft", "featuring", "feat", "feat.", "ft."]
     if for_artist:
         feat_words += ["with", "vs", "and", "con", "&"]
-    return r"(?<=[\s(\[])(?:{})(?=\s)".format(
-        "|".join(re.escape(x) for x in feat_words)
+    return (
+        rf"(?<=[\s(\[])(?:{'|'.join(re.escape(x) for x in feat_words)})(?=\s)"
     )
 
 
diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py
index 01030a9771..9f0ae82e17 100644
--- a/beets/ui/__init__.py
+++ b/beets/ui/__init__.py
@@ -125,7 +125,7 @@ def print_(*strings: str, end: str = "\n") -> None:
     The `end` keyword argument behaves similarly to the built-in `print`
     (it defaults to a newline).
     """
-    txt = " ".join(strings or ("",)) + end
+    txt = f"{' '.join(strings or ('',))}{end}"
 
     # Encode the string and write it to stdout.
     # On Python 3, sys.stdout expects text strings and uses the
@@ -269,7 +269,7 @@ def input_options(
             )
         ):
             # The first option is the default; mark it.
-            show_letter = "[%s]" % found_letter.upper()
+            show_letter = f"[{found_letter.upper()}]"
             is_default = True
         else:
             show_letter = found_letter.upper()
@@ -308,9 +308,9 @@ def input_options(
             if isinstance(default, int):
                 default_name = str(default)
                 default_name = colorize("action_default", default_name)
-                tmpl = "# selection (default %s)"
-                prompt_parts.append(tmpl % default_name)
-                prompt_part_lengths.append(len(tmpl % str(default)))
+                tmpl = "# selection (default {})"
+                prompt_parts.append(tmpl.format(default_name))
+                prompt_part_lengths.append(len(tmpl) - 2 + len(str(default)))
             else:
                 prompt_parts.append("# selection")
                 prompt_part_lengths.append(len(prompt_parts[-1]))
@@ -338,7 +338,7 @@ def input_options(
 
             if line_length != 0:
                 # Not the beginning of the line; need a space.
-                part = " " + part
+                part = f" {part}"
                 length += 1
 
             prompt += part
@@ -349,8 +349,8 @@ def input_options(
     if not fallback_prompt:
         fallback_prompt = "Enter one of "
         if numrange:
-            fallback_prompt += "%i-%i, " % numrange
-        fallback_prompt += ", ".join(display_letters) + ":"
+            fallback_prompt += "{}-{}, ".format(*numrange)
+        fallback_prompt += f"{', '.join(display_letters)}:"
 
     resp = input_(prompt)
     while True:
@@ -406,7 +406,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None):
     objects individually.
     """
     choice = input_options(
-        ("y", "n", "s"), False, "%s? (Yes/no/select)" % (prompt_all or prompt)
+        ("y", "n", "s"), False, f"{prompt_all or prompt}? (Yes/no/select)"
     )
     print()  # Blank line.
 
@@ -420,7 +420,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None):
             answer = input_options(
                 ("y", "n", "q"),
                 True,
-                "%s? (yes/no/quit)" % prompt,
+                f"{prompt}? (yes/no/quit)",
                 "Enter Y or N:",
             )
             if answer == "y":
@@ -494,7 +494,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None):
     "bg_cyan": 46,
     "bg_white": 47,
 }
-RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
+RESET_COLOR = f"{COLOR_ESCAPE}39;49;00m"
 
 # These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS
 # as they are defined in the configuration files, see function: colorize
@@ -534,8 +534,8 @@ def _colorize(color, text):
     # over all "ANSI codes" in `color`.
     escape = ""
     for code in color:
-        escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code]
-    return escape + text + RESET_COLOR
+        escape = f"{escape}{COLOR_ESCAPE}{ANSI_CODES[code]}m"
+    return f"{escape}{text}{RESET_COLOR}"
 
 
 def colorize(color_name, text):
@@ -572,7 +572,7 @@ def colorize(color_name, text):
         # instead of the abstract color name ('text_error')
         color = COLORS.get(color_name)
         if not color:
-            log.debug("Invalid color_name: {0}", color_name)
+            log.debug("Invalid color_name: {}", color_name)
             color = color_name
         return _colorize(color, text)
     else:
@@ -621,8 +621,8 @@ def color_split(colored_text, index):
                     split_index = index - (length - color_len(part))
                     found_split = True
                     if found_color_code:
-                        pre_split += part[:split_index] + RESET_COLOR
-                        post_split += found_color_code + part[split_index:]
+                        pre_split += f"{part[:split_index]}{RESET_COLOR}"
+                        post_split += f"{found_color_code}{part[split_index:]}"
                     else:
                         pre_split += part[:split_index]
                         post_split += part[split_index:]
@@ -726,7 +726,7 @@ def get_replacements():
             replacements.append((re.compile(pattern), repl))
         except re.error:
             raise UserError(
-                "malformed regular expression in replace: {}".format(pattern)
+                f"malformed regular expression in replace: {pattern}"
             )
     return replacements
 
@@ -806,17 +806,17 @@ def split_into_lines(string, width_tuple):
                 # Colorize each word with pre/post escapes
                 # Reconstruct colored words
                 words += [
-                    m.group("esc") + raw_word + RESET_COLOR
+                    f"{m['esc']}{raw_word}{RESET_COLOR}"
                     for raw_word in raw_words
                 ]
             elif raw_words:
                 # Pretext stops mid-word
                 if m.group("esc") != RESET_COLOR:
                     # Add the rest of the current word, with a reset after it
-                    words[-1] += m.group("esc") + raw_words[0] + RESET_COLOR
+                    words[-1] += f"{m['esc']}{raw_words[0]}{RESET_COLOR}"
                     # Add the subsequent colored words:
                     words += [
-                        m.group("esc") + raw_word + RESET_COLOR
+                        f"{m['esc']}{raw_word}{RESET_COLOR}"
                         for raw_word in raw_words[1:]
                     ]
                 else:
@@ -907,18 +907,12 @@ def print_column_layout(
     With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the
     rest of contents, wrapped if the width would be otherwise exceeded.
     """
-    if right["prefix"] + right["contents"] + right["suffix"] == "":
+    if f"{right['prefix']}{right['contents']}{right['suffix']}" == "":
         # No right hand information, so we don't need a separator.
         separator = ""
     first_line_no_wrap = (
-        indent_str
-        + left["prefix"]
-        + left["contents"]
-        + left["suffix"]
-        + separator
-        + right["prefix"]
-        + right["contents"]
-        + right["suffix"]
+        f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}"
+        f"{separator}{right['prefix']}{right['contents']}{right['suffix']}"
     )
     if color_len(first_line_no_wrap) < max_width:
         # Everything fits, print out line.
@@ -1044,18 +1038,12 @@ def print_newline_layout(
     If {lhs0} would go over the maximum width, the subsequent lines are
     indented a second time for ease of reading.
     """
-    if right["prefix"] + right["contents"] + right["suffix"] == "":
+    if f"{right['prefix']}{right['contents']}{right['suffix']}" == "":
         # No right hand information, so we don't need a separator.
         separator = ""
     first_line_no_wrap = (
-        indent_str
-        + left["prefix"]
-        + left["contents"]
-        + left["suffix"]
-        + separator
-        + right["prefix"]
-        + right["contents"]
-        + right["suffix"]
+        f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}"
+        f"{separator}{right['prefix']}{right['contents']}{right['suffix']}"
     )
     if color_len(first_line_no_wrap) < max_width:
         # Everything fits, print out line.
@@ -1069,7 +1057,7 @@ def print_newline_layout(
             empty_space - len(indent_str),
             empty_space - len(indent_str),
         )
-        left_str = left["prefix"] + left["contents"] + left["suffix"]
+        left_str = f"{left['prefix']}{left['contents']}{left['suffix']}"
         left_split = split_into_lines(left_str, left_width_tuple)
         # Repeat calculations for rhs, including separator on first line
         right_width_tuple = (
@@ -1077,19 +1065,19 @@ def print_newline_layout(
             empty_space - len(indent_str),
             empty_space - len(indent_str),
         )
-        right_str = right["prefix"] + right["contents"] + right["suffix"]
+        right_str = f"{right['prefix']}{right['contents']}{right['suffix']}"
         right_split = split_into_lines(right_str, right_width_tuple)
         for i, line in enumerate(left_split):
             if i == 0:
-                print_(indent_str + line)
+                print_(f"{indent_str}{line}")
             elif line != "":
                 # Ignore empty lines
-                print_(indent_str * 2 + line)
+                print_(f"{indent_str * 2}{line}")
         for i, line in enumerate(right_split):
             if i == 0:
-                print_(indent_str + separator + line)
+                print_(f"{indent_str}{separator}{line}")
             elif line != "":
-                print_(indent_str * 2 + line)
+                print_(f"{indent_str * 2}{line}")
 
 
 FLOAT_EPSILON = 0.01
@@ -1163,7 +1151,7 @@ def show_model_changes(new, old=None, fields=None, always=False):
             continue
 
         changes.append(
-            "  {}: {}".format(field, colorize("text_highlight", new_fmt[field]))
+            f"  {field}: {colorize('text_highlight', new_fmt[field])}"
         )
 
     # Print changes.
@@ -1204,22 +1192,16 @@ def show_path_changes(path_changes):
         # Print every change over two lines
         for source, dest in zip(sources, destinations):
             color_source, color_dest = colordiff(source, dest)
-            print_("{0} \n  -> {1}".format(color_source, color_dest))
+            print_(f"{color_source} \n  -> {color_dest}")
     else:
         # Print every change on a single line, and add a header
         title_pad = max_width - len("Source ") + len(" -> ")
 
-        print_("Source {0} Destination".format(" " * title_pad))
+        print_(f"Source {' ' * title_pad} Destination")
         for source, dest in zip(sources, destinations):
             pad = max_width - len(source)
             color_source, color_dest = colordiff(source, dest)
-            print_(
-                "{0} {1} -> {2}".format(
-                    color_source,
-                    " " * pad,
-                    color_dest,
-                )
-            )
+            print_(f"{color_source} {' ' * pad} -> {color_dest}")
 
 
 # Helper functions for option parsing.
@@ -1245,9 +1227,7 @@ def _store_dict(option, opt_str, value, parser):
             raise ValueError
     except ValueError:
         raise UserError(
-            "supplied argument `{}' is not of the form `key=value'".format(
-                value
-            )
+            f"supplied argument `{value}' is not of the form `key=value'"
         )
 
     option_values[key] = value
@@ -1426,8 +1406,8 @@ def root_parser(self):
     @root_parser.setter
     def root_parser(self, root_parser):
         self._root_parser = root_parser
-        self.parser.prog = "{} {}".format(
-            as_string(root_parser.get_prog_name()), self.name
+        self.parser.prog = (
+            f"{as_string(root_parser.get_prog_name())} {self.name}"
         )
 
 
@@ -1483,7 +1463,7 @@ def format_help(self, formatter=None):
         for subcommand in subcommands:
             name = subcommand.name
             if subcommand.aliases:
-                name += " (%s)" % ", ".join(subcommand.aliases)
+                name += f" ({', '.join(subcommand.aliases)})"
             disp_names.append(name)
 
             # Set the help position based on the max width.
@@ -1496,32 +1476,24 @@ def format_help(self, formatter=None):
             # Lifted directly from optparse.py.
             name_width = help_position - formatter.current_indent - 2
             if len(name) > name_width:
-                name = "%*s%s\n" % (formatter.current_indent, "", name)
+                name = f"{' ' * formatter.current_indent}{name}\n"
                 indent_first = help_position
             else:
-                name = "%*s%-*s  " % (
-                    formatter.current_indent,
-                    "",
-                    name_width,
-                    name,
-                )
+                name = f"{' ' * formatter.current_indent}{name:<{name_width}}\n"
                 indent_first = 0
             result.append(name)
             help_width = formatter.width - help_position
             help_lines = textwrap.wrap(subcommand.help, help_width)
             help_line = help_lines[0] if help_lines else ""
-            result.append("%*s%s\n" % (indent_first, "", help_line))
+            result.append(f"{' ' * indent_first}{help_line}\n")
             result.extend(
-                [
-                    "%*s%s\n" % (help_position, "", line)
-                    for line in help_lines[1:]
-                ]
+                [f"{' ' * help_position}{line}\n" for line in help_lines[1:]]
             )
         formatter.dedent()
 
         # Concatenate the original help message with the subcommand
         # list.
-        return out + "".join(result)
+        return f"{out}{''.join(result)}"
 
     def _subcommand_for_name(self, name):
         """Return the subcommand in self.subcommands matching the
@@ -1615,19 +1587,19 @@ def _configure(options):
 
     if overlay_path:
         log.debug(
-            "overlaying configuration: {0}", util.displayable_path(overlay_path)
+            "overlaying configuration: {}", util.displayable_path(overlay_path)
         )
 
     config_path = config.user_config_path()
     if os.path.isfile(config_path):
-        log.debug("user configuration: {0}", util.displayable_path(config_path))
+        log.debug("user configuration: {}", util.displayable_path(config_path))
     else:
         log.debug(
-            "no user configuration found at {0}",
+            "no user configuration found at {}",
             util.displayable_path(config_path),
         )
 
-    log.debug("data directory: {0}", util.displayable_path(config.config_dir()))
+    log.debug("data directory: {}", util.displayable_path(config.config_dir()))
     return config
 
 
@@ -1637,10 +1609,8 @@ def _ensure_db_directory_exists(path):
     newpath = os.path.dirname(path)
     if not os.path.isdir(newpath):
         if input_yn(
-            "The database directory {} does not \
-                       exist. Create it (Y/n)?".format(
-                util.displayable_path(newpath)
-            )
+            f"The database directory {util.displayable_path(newpath)} does not"
+            " exist. Create it (Y/n)?"
         ):
             os.makedirs(newpath)
 
@@ -1660,12 +1630,11 @@ def _open_library(config: confuse.LazyConfig) -> library.Library:
     except (sqlite3.OperationalError, sqlite3.DatabaseError) as db_error:
         log.debug("{}", traceback.format_exc())
         raise UserError(
-            "database file {} cannot not be opened: {}".format(
-                util.displayable_path(dbpath), db_error
-            )
+            f"database file {util.displayable_path(dbpath)} cannot not be"
+            f" opened: {db_error}"
         )
     log.debug(
-        "library database: {0}\nlibrary directory: {1}",
+        "library database: {}\nlibrary directory: {}",
         util.displayable_path(lib.path),
         util.displayable_path(lib.directory),
     )
@@ -1782,7 +1751,7 @@ def main(args=None):
         _raw_main(args)
     except UserError as exc:
         message = exc.args[0] if exc.args else None
-        log.error("error: {0}", message)
+        log.error("error: {}", message)
         sys.exit(1)
     except util.HumanReadableError as exc:
         exc.log(log)
@@ -1794,10 +1763,10 @@ def main(args=None):
         log.error("{}", exc)
         sys.exit(1)
     except confuse.ConfigError as exc:
-        log.error("configuration error: {0}", exc)
+        log.error("configuration error: {}", exc)
         sys.exit(1)
     except db_query.InvalidQueryError as exc:
-        log.error("invalid query: {0}", exc)
+        log.error("invalid query: {}", exc)
         sys.exit(1)
     except OSError as exc:
         if exc.errno == errno.EPIPE:
@@ -1810,7 +1779,7 @@ def main(args=None):
         log.debug("{}", traceback.format_exc())
     except db.DBAccessError as exc:
         log.error(
-            "database access error: {0}\n"
+            "database access error: {}\n"
             "the library file might have a permissions problem",
             exc,
         )
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
index 12a8d68750..911a5cfd3d 100755
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -18,6 +18,7 @@
 
 import os
 import re
+import textwrap
 from collections import Counter
 from collections.abc import Sequence
 from itertools import chain
@@ -112,15 +113,11 @@ def _parse_logfiles(logfiles):
             yield from _paths_from_logfile(syspath(normpath(logfile)))
         except ValueError as err:
             raise ui.UserError(
-                "malformed logfile {}: {}".format(
-                    util.displayable_path(logfile), str(err)
-                )
+                f"malformed logfile {util.displayable_path(logfile)}: {err}"
             ) from err
         except OSError as err:
             raise ui.UserError(
-                "unreadable logfile {}: {}".format(
-                    util.displayable_path(logfile), str(err)
-                )
+                f"unreadable logfile {util.displayable_path(logfile)}: {err}"
             ) from err
 
 
@@ -132,13 +129,13 @@ def _print_keys(query):
     returned row, with indentation of 2 spaces.
     """
     for row in query:
-        print_(" " * 2 + row["key"])
+        print_(f"  {row['key']}")
 
 
 def fields_func(lib, opts, args):
     def _print_rows(names):
         names.sort()
-        print_("  " + "\n  ".join(names))
+        print_(textwrap.indent("\n".join(names), "  "))
 
     print_("Item fields:")
     _print_rows(library.Item.all_keys())
@@ -148,13 +145,13 @@ def _print_rows(names):
 
     with lib.transaction() as tx:
         # The SQL uses the DISTINCT to get unique values from the query
-        unique_fields = "SELECT DISTINCT key FROM (%s)"
+        unique_fields = "SELECT DISTINCT key FROM ({})"
 
         print_("Item flexible attributes:")
-        _print_keys(tx.query(unique_fields % library.Item._flex_table))
+        _print_keys(tx.query(unique_fields.format(library.Item._flex_table)))
 
         print_("Album flexible attributes:")
-        _print_keys(tx.query(unique_fields % library.Album._flex_table))
+        _print_keys(tx.query(unique_fields.format(library.Album._flex_table)))
 
 
 fields_cmd = ui.Subcommand(
@@ -213,10 +210,10 @@ def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
     out = []
     chosen_fields = config["match"]["singleton_disambig_fields"].as_str_seq()
     calculated_values = {
-        "index": "Index {}".format(str(info.index)),
-        "track_alt": "Track {}".format(info.track_alt),
+        "index": f"Index {info.index}",
+        "track_alt": f"Track {info.track_alt}",
         "album": (
-            "[{}]".format(info.album)
+            f"[{info.album}]"
             if (
                 config["import"]["singleton_album_disambig"].get()
                 and info.get("album")
@@ -242,7 +239,7 @@ def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]:
     chosen_fields = config["match"]["album_disambig_fields"].as_str_seq()
     calculated_values = {
         "media": (
-            "{}x{}".format(info.mediums, info.media)
+            f"{info.mediums}x{info.media}"
             if (info.mediums and info.mediums > 1)
             else info.media
         ),
@@ -277,7 +274,7 @@ def dist_string(dist):
     """Formats a distance (a float) as a colorized similarity percentage
     string.
     """
-    string = "{:.1f}%".format(((1 - dist) * 100))
+    string = f"{(1 - dist) * 100:.1f}%"
     return dist_colorize(string, dist)
 
 
@@ -295,7 +292,7 @@ def penalty_string(distance, limit=None):
         if limit and len(penalties) > limit:
             penalties = penalties[:limit] + ["..."]
         # Prefix penalty string with U+2260: Not Equal To
-        penalty_string = "\u2260 {}".format(", ".join(penalties))
+        penalty_string = f"\u2260 {', '.join(penalties)}"
         return ui.colorize("changed", penalty_string)
 
 
@@ -360,18 +357,18 @@ def show_match_header(self):
 
         # 'Match' line and similarity.
         print_(
-            self.indent_header + f"Match ({dist_string(self.match.distance)}):"
+            f"{self.indent_header}Match ({dist_string(self.match.distance)}):"
         )
 
         if isinstance(self.match.info, autotag.hooks.AlbumInfo):
             # Matching an album - print that
             artist_album_str = (
-                f"{self.match.info.artist}" + f" - {self.match.info.album}"
+                f"{self.match.info.artist} - {self.match.info.album}"
             )
         else:
             # Matching a single track
             artist_album_str = (
-                f"{self.match.info.artist}" + f" - {self.match.info.title}"
+                f"{self.match.info.artist} - {self.match.info.title}"
             )
         print_(
             self.indent_header
@@ -381,22 +378,23 @@ def show_match_header(self):
         # Penalties.
         penalties = penalty_string(self.match.distance)
         if penalties:
-            print_(self.indent_header + penalties)
+            print_(f"{self.indent_header}{penalties}")
 
         # Disambiguation.
         disambig = disambig_string(self.match.info)
         if disambig:
-            print_(self.indent_header + disambig)
+            print_(f"{self.indent_header}{disambig}")
 
         # Data URL.
         if self.match.info.data_url:
             url = ui.colorize("text_faint", f"{self.match.info.data_url}")
-            print_(self.indent_header + url)
+            print_(f"{self.indent_header}{url}")
 
     def show_match_details(self):
         """Print out the details of the match, including changes in album name
         and artist name.
         """
+        changed_prefix = ui.colorize("changed", "\u2260")
         # Artist.
         artist_l, artist_r = self.cur_artist or "", self.match.info.artist
         if artist_r == VARIOUS_ARTISTS:
@@ -406,7 +404,7 @@ def show_match_details(self):
             artist_l, artist_r = ui.colordiff(artist_l, artist_r)
             # Prefix with U+2260: Not Equal To
             left = {
-                "prefix": ui.colorize("changed", "\u2260") + " Artist: ",
+                "prefix": f"{changed_prefix} Artist: ",
                 "contents": artist_l,
                 "suffix": "",
             }
@@ -414,7 +412,7 @@ def show_match_details(self):
             self.print_layout(self.indent_detail, left, right)
 
         else:
-            print_(self.indent_detail + "*", "Artist:", artist_r)
+            print_(f"{self.indent_detail}*", "Artist:", artist_r)
 
         if self.cur_album:
             # Album
@@ -426,14 +424,14 @@ def show_match_details(self):
                 album_l, album_r = ui.colordiff(album_l, album_r)
                 # Prefix with U+2260: Not Equal To
                 left = {
-                    "prefix": ui.colorize("changed", "\u2260") + " Album: ",
+                    "prefix": f"{changed_prefix} Album: ",
                     "contents": album_l,
                     "suffix": "",
                 }
                 right = {"prefix": "", "contents": album_r, "suffix": ""}
                 self.print_layout(self.indent_detail, left, right)
             else:
-                print_(self.indent_detail + "*", "Album:", album_r)
+                print_(f"{self.indent_detail}*", "Album:", album_r)
         elif self.cur_title:
             # Title - for singletons
             title_l, title_r = self.cur_title or "", self.match.info.title
@@ -441,14 +439,14 @@ def show_match_details(self):
                 title_l, title_r = ui.colordiff(title_l, title_r)
                 # Prefix with U+2260: Not Equal To
                 left = {
-                    "prefix": ui.colorize("changed", "\u2260") + " Title: ",
+                    "prefix": f"{changed_prefix} Title: ",
                     "contents": title_l,
                     "suffix": "",
                 }
                 right = {"prefix": "", "contents": title_r, "suffix": ""}
                 self.print_layout(self.indent_detail, left, right)
             else:
-                print_(self.indent_detail + "*", "Title:", title_r)
+                print_(f"{self.indent_detail}*", "Title:", title_r)
 
     def make_medium_info_line(self, track_info):
         """Construct a line with the current medium's info."""
@@ -490,7 +488,6 @@ def make_track_numbers(self, item, track_info):
         """Format colored track indices."""
         cur_track = self.format_index(item)
         new_track = self.format_index(track_info)
-        templ = "(#{})"
         changed = False
         # Choose color based on change.
         if cur_track != new_track:
@@ -502,10 +499,8 @@ def make_track_numbers(self, item, track_info):
         else:
             highlight_color = "text_faint"
 
-        cur_track = templ.format(cur_track)
-        new_track = templ.format(new_track)
-        lhs_track = ui.colorize(highlight_color, cur_track)
-        rhs_track = ui.colorize(highlight_color, new_track)
+        lhs_track = ui.colorize(highlight_color, f"(#{cur_track})")
+        rhs_track = ui.colorize(highlight_color, f"(#{new_track})")
         return lhs_track, rhs_track, changed
 
     @staticmethod
@@ -575,9 +570,9 @@ def make_line(self, item, track_info):
 
         prefix = ui.colorize("changed", "\u2260 ") if changed else "* "
         lhs = {
-            "prefix": prefix + lhs_track + " ",
+            "prefix": f"{prefix}{lhs_track} ",
             "contents": lhs_title,
-            "suffix": " " + lhs_length,
+            "suffix": f" {lhs_length}",
         }
         rhs = {"prefix": "", "contents": "", "suffix": ""}
         if not changed:
@@ -586,9 +581,9 @@ def make_line(self, item, track_info):
         else:
             # Construct a dictionary for the "changed to" side
             rhs = {
-                "prefix": rhs_track + " ",
+                "prefix": f"{rhs_track} ",
                 "contents": rhs_title,
-                "suffix": " " + rhs_length,
+                "suffix": f" {rhs_length}",
             }
             return (lhs, rhs)
 
@@ -681,7 +676,7 @@ def show_match_tracks(self):
                     # Print tracks from previous medium
                     self.print_tracklist(lines)
                     lines = []
-                    print_(self.indent_detail + header)
+                    print_(f"{self.indent_detail}{header}")
                 # Save new medium details for future comparison.
                 medium, disctitle = track_info.medium, track_info.disctitle
 
@@ -697,11 +692,9 @@ def show_match_tracks(self):
         # Missing and unmatched tracks.
         if self.match.extra_tracks:
             print_(
-                "Missing tracks ({0}/{1} - {2:.1%}):".format(
-                    len(self.match.extra_tracks),
-                    len(self.match.info.tracks),
-                    len(self.match.extra_tracks) / len(self.match.info.tracks),
-                )
+                "Missing tracks"
+                f" ({len(self.match.extra_tracks)}/{len(self.match.info.tracks)} -"
+                f" {len(self.match.extra_tracks) / len(self.match.info.tracks):.1%}):"
             )
         for track_info in self.match.extra_tracks:
             line = f" ! {track_info.title} (#{self.format_index(track_info)})"
@@ -711,9 +704,9 @@ def show_match_tracks(self):
         if self.match.extra_items:
             print_(f"Unmatched tracks ({len(self.match.extra_items)}):")
         for item in self.match.extra_items:
-            line = " ! {} (#{})".format(item.title, self.format_index(item))
+            line = f" ! {item.title} (#{self.format_index(item)})"
             if item.length:
-                line += " ({})".format(human_seconds_short(item.length))
+                line += f" ({human_seconds_short(item.length)})"
             print_(ui.colorize("text_warning", line))
 
 
@@ -769,7 +762,7 @@ def summarize_items(items, singleton):
     """
     summary_parts = []
     if not singleton:
-        summary_parts.append("{} items".format(len(items)))
+        summary_parts.append(f"{len(items)} items")
 
     format_counts = {}
     for item in items:
@@ -789,10 +782,11 @@ def summarize_items(items, singleton):
         average_bitrate = sum([item.bitrate for item in items]) / len(items)
         total_duration = sum([item.length for item in items])
         total_filesize = sum([item.filesize for item in items])
-        summary_parts.append("{}kbps".format(int(average_bitrate / 1000)))
+        summary_parts.append(f"{int(average_bitrate / 1000)}kbps")
         if items[0].format == "FLAC":
-            sample_bits = "{}kHz/{} bit".format(
-                round(int(items[0].samplerate) / 1000, 1), items[0].bitdepth
+            sample_bits = (
+                f"{round(int(items[0].samplerate) / 1000, 1)}kHz"
+                f"/{items[0].bitdepth} bit"
             )
             summary_parts.append(sample_bits)
         summary_parts.append(human_seconds_short(total_duration))
@@ -885,7 +879,7 @@ def choose_candidate(
         if singleton:
             print_("No matching recordings found.")
         else:
-            print_("No matching release found for {} tracks.".format(itemcount))
+            print_(f"No matching release found for {itemcount} tracks.")
             print_(
                 "For help, see: "
                 "https://beets.readthedocs.org/en/latest/faq.html#nomatch"
@@ -910,40 +904,38 @@ def choose_candidate(
             # Display list of candidates.
             print_("")
             print_(
-                'Finding tags for {} "{} - {}".'.format(
-                    "track" if singleton else "album",
-                    item.artist if singleton else cur_artist,
-                    item.title if singleton else cur_album,
-                )
+                f"Finding tags for {'track' if singleton else 'album'}"
+                f'"{item.artist if singleton else cur_artist} -'
+                f' {item.title if singleton else cur_album}".'
             )
 
-            print_(ui.indent(2) + "Candidates:")
+            print_("  Candidates:")
             for i, match in enumerate(candidates):
                 # Index, metadata, and distance.
-                index0 = "{0}.".format(i + 1)
+                index0 = f"{i + 1}."
                 index = dist_colorize(index0, match.distance)
-                dist = "({:.1f}%)".format((1 - match.distance) * 100)
+                dist = f"({(1 - match.distance) * 100:.1f}%)"
                 distance = dist_colorize(dist, match.distance)
-                metadata = "{0} - {1}".format(
-                    match.info.artist,
-                    match.info.title if singleton else match.info.album,
+                metadata = (
+                    f"{match.info.artist} -"
+                    f" {match.info.title if singleton else match.info.album}"
                 )
                 if i == 0:
                     metadata = dist_colorize(metadata, match.distance)
                 else:
                     metadata = ui.colorize("text_highlight_minor", metadata)
                 line1 = [index, distance, metadata]
-                print_(ui.indent(2) + " ".join(line1))
+                print_(f"  {' '.join(line1)}")
 
                 # Penalties.
                 penalties = penalty_string(match.distance, 3)
                 if penalties:
-                    print_(ui.indent(13) + penalties)
+                    print_(f"{' ' * 13}{penalties}")
 
                 # Disambiguation
                 disambig = disambig_string(match.info)
                 if disambig:
-                    print_(ui.indent(13) + disambig)
+                    print_(f"{' ' * 13}{disambig}")
 
             # Ask the user for a choice.
             sel = ui.input_options(choice_opts, numrange=(1, len(candidates)))
@@ -1015,7 +1007,7 @@ def manual_id(session, task):
 
     Input an ID, either for an album ("release") or a track ("recording").
     """
-    prompt = "Enter {} ID:".format("release" if task.is_album else "recording")
+    prompt = f"Enter {'release' if task.is_album else 'recording'} ID:"
     search_id = input_(prompt).strip()
 
     if task.is_album:
@@ -1043,7 +1035,7 @@ def choose_match(self, task):
 
         path_str0 = displayable_path(task.paths, "\n")
         path_str = ui.colorize("import_path", path_str0)
-        items_str0 = "({} items)".format(len(task.items))
+        items_str0 = f"({len(task.items)} items)"
         items_str = ui.colorize("import_path_items", items_str0)
         print_(" ".join([path_str, items_str]))
 
@@ -1156,7 +1148,7 @@ def resolve_duplicate(self, task, found_duplicates):
         that's already in the library.
         """
         log.warning(
-            "This {0} is already in the library!",
+            "This {} is already in the library!",
             ("album" if task.is_album else "item"),
         )
 
@@ -1217,8 +1209,8 @@ def resolve_duplicate(self, task, found_duplicates):
 
     def should_resume(self, path):
         return ui.input_yn(
-            "Import of the directory:\n{}\n"
-            "was interrupted. Resume (Y/n)?".format(displayable_path(path))
+            f"Import of the directory:\n{displayable_path(path)}\n"
+            "was interrupted. Resume (Y/n)?"
         )
 
     def _get_choices(self, task):
@@ -1288,11 +1280,10 @@ def _get_choices(self, task):
                 dup_choices = [c for c in all_choices if c.short == short]
                 for c in dup_choices[1:]:
                     log.warning(
-                        "Prompt choice '{0}' removed due to conflict "
-                        "with '{1}' (short letter: '{2}')",
-                        c.long,
-                        dup_choices[0].long,
-                        c.short,
+                        "Prompt choice '{0.long}' removed due to conflict "
+                        "with '{1[0].long}' (short letter: '{0.short}')",
+                        c,
+                        dup_choices,
                     )
                     extra_choices.remove(c)
 
@@ -1317,7 +1308,8 @@ def import_files(lib, paths: list[bytes], query):
             loghandler = logging.FileHandler(logpath, encoding="utf-8")
         except OSError:
             raise ui.UserError(
-                f"Could not open log file for writing: {displayable_path(logpath)}"
+                "Could not open log file for writing:"
+                f" {displayable_path(logpath)}"
             )
     else:
         loghandler = None
@@ -1362,9 +1354,7 @@ def import_func(lib, opts, args: list[str]):
         for path in byte_paths:
             if not os.path.exists(syspath(normpath(path))):
                 raise ui.UserError(
-                    "no such file or directory: {}".format(
-                        displayable_path(path)
-                    )
+                    f"no such file or directory: {displayable_path(path)}"
                 )
 
         # Check the directories from the logfiles, but don't throw an error in
@@ -1374,9 +1364,7 @@ def import_func(lib, opts, args: list[str]):
         for path in paths_from_logfiles:
             if not os.path.exists(syspath(normpath(path))):
                 log.warning(
-                    "No such file or directory: {}".format(
-                        displayable_path(path)
-                    )
+                    "No such file or directory: {}", displayable_path(path)
                 )
                 continue
 
@@ -1650,9 +1638,8 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None):
             # Did the item change since last checked?
             if item.current_mtime() <= item.mtime:
                 log.debug(
-                    "skipping {0} because mtime is up to date ({1})",
-                    displayable_path(item.path),
-                    item.mtime,
+                    "skipping {0.filepath} because mtime is up to date ({0.mtime})",
+                    item,
                 )
                 continue
 
@@ -1660,9 +1647,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None):
             try:
                 item.read()
             except library.ReadError as exc:
-                log.error(
-                    "error reading {0}: {1}", displayable_path(item.path), exc
-                )
+                log.error("error reading {.filepath}: {}", item, exc)
                 continue
 
             # Special-case album artist when it matches track artist. (Hacky
@@ -1703,7 +1688,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None):
                 continue
             album = lib.get_album(album_id)
             if not album:  # Empty albums have already been removed.
-                log.debug("emptied album {0}", album_id)
+                log.debug("emptied album {}", album_id)
                 continue
             first_item = album.items().get()
 
@@ -1714,7 +1699,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None):
 
             # Move album art (and any inconsistent items).
             if move and lib.directory in ancestry(first_item.path):
-                log.debug("moving album {0}", album_id)
+                log.debug("moving album {}", album_id)
 
                 # Manually moving and storing the album.
                 items = list(album.items())
@@ -1808,7 +1793,7 @@ def remove_items(lib, query, album, delete, force):
     if not force:
         # Prepare confirmation with user.
         album_str = (
-            " in {} album{}".format(len(albums), "s" if len(albums) > 1 else "")
+            f" in {len(albums)} album{'s' if len(albums) > 1 else ''}"
             if album
             else ""
         )
@@ -1816,14 +1801,17 @@ def remove_items(lib, query, album, delete, force):
         if delete:
             fmt = "$path - $title"
             prompt = "Really DELETE"
-            prompt_all = "Really DELETE {} file{}{}".format(
-                len(items), "s" if len(items) > 1 else "", album_str
+            prompt_all = (
+                "Really DELETE"
+                f" {len(items)} file{'s' if len(items) > 1 else ''}{album_str}"
             )
         else:
             fmt = ""
             prompt = "Really remove from the library?"
-            prompt_all = "Really remove {} item{}{} from the library?".format(
-                len(items), "s" if len(items) > 1 else "", album_str
+            prompt_all = (
+                "Really remove"
+                f" {len(items)} item{'s' if len(items) > 1 else ''}{album_str}"
+                " from the library?"
             )
 
         # Helpers for printing affected items
@@ -1892,7 +1880,7 @@ def show_stats(lib, query, exact):
             try:
                 total_size += os.path.getsize(syspath(item.path))
             except OSError as exc:
-                log.info("could not get size of {}: {}", item.path, exc)
+                log.info("could not get size of {.path}: {}", item, exc)
         else:
             total_size += int(item.length * item.bitrate / 8)
         total_time += item.length
@@ -1902,27 +1890,17 @@ def show_stats(lib, query, exact):
         if item.album_id:
             albums.add(item.album_id)
 
-    size_str = "" + human_bytes(total_size)
+    size_str = human_bytes(total_size)
     if exact:
         size_str += f" ({total_size} bytes)"
 
-    print_(
-        """Tracks: {}
-Total time: {}{}
-{}: {}
-Artists: {}
-Albums: {}
-Album artists: {}""".format(
-            total_items,
-            human_seconds(total_time),
-            f" ({total_time:.2f} seconds)" if exact else "",
-            "Total size" if exact else "Approximate total size",
-            size_str,
-            len(artists),
-            len(albums),
-            len(album_artists),
-        ),
-    )
+    print_(f"""Tracks: {total_items}
+Total time: {human_seconds(total_time)}
+{f" ({total_time:.2f} seconds)" if exact else ""}
+{"Total size" if exact else "Approximate total size"}: {size_str}
+Artists: {len(artists)}
+Albums: {len(albums)}
+Album artists: {len(album_artists)}""")
 
 
 def stats_func(lib, opts, args):
@@ -1943,7 +1921,7 @@ def stats_func(lib, opts, args):
 
 
 def show_version(lib, opts, args):
-    print_("beets version %s" % beets.__version__)
+    print_(f"beets version {beets.__version__}")
     print_(f"Python version {python_version()}")
     # Show plugins.
     names = sorted(p.name for p in plugins.find_plugins())
@@ -1977,7 +1955,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit):
 
     # Apply changes *temporarily*, preview them, and collect modified
     # objects.
-    print_("Modifying {} {}s.".format(len(objs), "album" if album else "item"))
+    print_(f"Modifying {len(objs)} {'album' if album else 'item'}s.")
     changed = []
     templates = {
         key: functemplate.template(value) for key, value in mods.items()
@@ -2007,7 +1985,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm, inherit):
             extra = ""
 
         changed = ui.input_select_objects(
-            "Really modify%s" % extra,
+            f"Really modify{extra}",
             changed,
             lambda o: print_and_modify(o, mods, dels),
         )
@@ -2159,7 +2137,7 @@ def isalbummoved(album):
     act = "copy" if copy else "move"
     entity = "album" if album else "item"
     log.info(
-        "{0} {1} {2}{3}{4}.",
+        "{} {} {}{}{}.",
         action,
         len(objs),
         entity,
@@ -2185,7 +2163,7 @@ def isalbummoved(album):
     else:
         if confirm:
             objs = ui.input_select_objects(
-                "Really %s" % act,
+                f"Really {act}",
                 objs,
                 lambda o: show_path_changes(
                     [(o.path, o.destination(basedir=dest))]
@@ -2193,7 +2171,7 @@ def isalbummoved(album):
             )
 
         for obj in objs:
-            log.debug("moving: {0}", util.displayable_path(obj.path))
+            log.debug("moving: {.filepath}", obj)
 
             if export:
                 # Copy without affecting the database.
@@ -2213,9 +2191,7 @@ def move_func(lib, opts, args):
     if dest is not None:
         dest = normpath(dest)
         if not os.path.isdir(syspath(dest)):
-            raise ui.UserError(
-                "no such directory: {}".format(displayable_path(dest))
-            )
+            raise ui.UserError(f"no such directory: {displayable_path(dest)}")
 
     move_items(
         lib,
@@ -2278,16 +2254,14 @@ def write_items(lib, query, pretend, force):
     for item in items:
         # Item deleted?
         if not os.path.exists(syspath(item.path)):
-            log.info("missing file: {0}", util.displayable_path(item.path))
+            log.info("missing file: {.filepath}", item)
             continue
 
         # Get an Item object reflecting the "clean" (on-disk) state.
         try:
             clean_item = library.Item.from_path(item.path)
         except library.ReadError as exc:
-            log.error(
-                "error reading {0}: {1}", displayable_path(item.path), exc
-            )
+            log.error("error reading {.filepath}: {}", item, exc)
             continue
 
         # Check for and display changes.
@@ -2480,30 +2454,27 @@ def completion_script(commands):
     yield "_beet() {\n"
 
     # Command names
-    yield "  local commands='%s'\n" % " ".join(command_names)
+    yield f"  local commands={' '.join(command_names)!r}\n"
     yield "\n"
 
     # Command aliases
-    yield "  local aliases='%s'\n" % " ".join(aliases.keys())
+    yield f"  local aliases={' '.join(aliases.keys())!r}\n"
     for alias, cmd in aliases.items():
-        yield "  local alias__{}={}\n".format(alias.replace("-", "_"), cmd)
+        yield f"  local alias__{alias.replace('-', '_')}={cmd}\n"
     yield "\n"
 
     # Fields
-    yield "  fields='%s'\n" % " ".join(
-        set(
-            list(library.Item._fields.keys())
-            + list(library.Album._fields.keys())
-        )
-    )
+    fields = library.Item._fields.keys() | library.Album._fields.keys()
+    yield f"  fields={' '.join(fields)!r}\n"
 
     # Command options
     for cmd, opts in options.items():
         for option_type, option_list in opts.items():
             if option_list:
                 option_list = " ".join(option_list)
-                yield "  local {}__{}='{}'\n".format(
-                    option_type, cmd.replace("-", "_"), option_list
+                yield (
+                    "  local"
+                    f" {option_type}__{cmd.replace('-', '_')}='{option_list}'\n"
                 )
 
     yield "  _beet_dispatch\n"
diff --git a/beets/util/__init__.py b/beets/util/__init__.py
index e2f7f46bda..f895a60ee8 100644
--- a/beets/util/__init__.py
+++ b/beets/util/__init__.py
@@ -112,7 +112,7 @@ def _reasonstr(self):
         elif hasattr(self.reason, "strerror"):  # i.e., EnvironmentError
             return self.reason.strerror
         else:
-            return '"{}"'.format(str(self.reason))
+            return f'"{self.reason}"'
 
     def get_message(self):
         """Create the human-readable description of the error, sans
@@ -126,7 +126,7 @@ def log(self, logger):
         """
         if self.tb:
             logger.debug(self.tb)
-        logger.error("{0}: {1}", self.error_kind, self.args[0])
+        logger.error("{0.error_kind}: {0.args[0]}", self)
 
 
 class FilesystemError(HumanReadableError):
@@ -142,18 +142,16 @@ def __init__(self, reason, verb, paths, tb=None):
     def get_message(self):
         # Use a nicer English phrasing for some specific verbs.
         if self.verb in ("move", "copy", "rename"):
-            clause = "while {} {} to {}".format(
-                self._gerund(),
-                displayable_path(self.paths[0]),
-                displayable_path(self.paths[1]),
+            clause = (
+                f"while {self._gerund()} {displayable_path(self.paths[0])} to"
+                f" {displayable_path(self.paths[1])}"
             )
         elif self.verb in ("delete", "write", "create", "read"):
-            clause = "while {} {}".format(
-                self._gerund(), displayable_path(self.paths[0])
-            )
+            clause = f"while {self._gerund()} {displayable_path(self.paths[0])}"
         else:
-            clause = "during {} of paths {}".format(
-                self.verb, ", ".join(displayable_path(p) for p in self.paths)
+            clause = (
+                f"during {self.verb} of paths"
+                f" {', '.join(displayable_path(p) for p in self.paths)}"
             )
 
         return f"{self._reasonstr()} {clause}"
@@ -223,12 +221,12 @@ def sorted_walk(
     # Get all the directories and files at this level.
     try:
         contents = os.listdir(syspath(bytes_path))
-    except OSError as exc:
+    except OSError:
         if logger:
             logger.warning(
-                "could not list directory {}: {}".format(
-                    displayable_path(bytes_path), exc.strerror
-                )
+                "could not list directory {}",
+                displayable_path(bytes_path),
+                exc_info=True,
             )
         return
     dirs = []
@@ -436,8 +434,8 @@ def syspath(path: PathLike, prefix: bool = True) -> str:
     if prefix and not str_path.startswith(WINDOWS_MAGIC_PREFIX):
         if str_path.startswith("\\\\"):
             # UNC path. Final path should look like \\?\UNC\...
-            str_path = "UNC" + str_path[1:]
-        str_path = WINDOWS_MAGIC_PREFIX + str_path
+            str_path = f"UNC{str_path[1:]}"
+        str_path = f"{WINDOWS_MAGIC_PREFIX}{str_path}"
 
     return str_path
 
@@ -509,8 +507,8 @@ def move(path: bytes, dest: bytes, replace: bool = False):
         basename = os.path.basename(bytestring_path(dest))
         dirname = os.path.dirname(bytestring_path(dest))
         tmp = tempfile.NamedTemporaryFile(
-            suffix=syspath(b".beets", prefix=False),
-            prefix=syspath(b"." + basename + b".", prefix=False),
+            suffix=".beets",
+            prefix=f".{os.fsdecode(basename)}.",
             dir=syspath(dirname),
             delete=False,
         )
@@ -719,7 +717,7 @@ def truncate_path(str_path: str) -> str:
     path = Path(str_path)
     parent_parts = [truncate_str(p, max_length) for p in path.parts[:-1]]
     stem = truncate_str(path.stem, max_length - len(path.suffix))
-    return str(Path(*parent_parts, stem)) + path.suffix
+    return f"{Path(*parent_parts, stem)}{path.suffix}"
 
 
 def _legalize_stage(
diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py
index fe67c506e0..5ecde51407 100644
--- a/beets/util/artresizer.py
+++ b/beets/util/artresizer.py
@@ -54,7 +54,7 @@ def resize_url(url: str, maxwidth: int, quality: int = 0) -> str:
     if quality > 0:
         params["q"] = quality
 
-    return "{}?{}".format(PROXY_URL, urlencode(params))
+    return f"{PROXY_URL}?{urlencode(params)}"
 
 
 class LocalBackendNotAvailableError(Exception):
@@ -255,7 +255,7 @@ def resize(
             path_out = get_temp_filename(__name__, "resize_IM_", path_in)
 
         log.debug(
-            "artresizer: ImageMagick resizing {0} to {1}",
+            "artresizer: ImageMagick resizing {} to {}",
             displayable_path(path_in),
             displayable_path(path_out),
         )
@@ -287,7 +287,7 @@ def resize(
             util.command_output(cmd)
         except subprocess.CalledProcessError:
             log.warning(
-                "artresizer: IM convert failed for {0}",
+                "artresizer: IM convert failed for {}",
                 displayable_path(path_in),
             )
             return path_in
@@ -306,9 +306,9 @@ def get_size(self, path_in: bytes) -> tuple[int, int] | None:
         except subprocess.CalledProcessError as exc:
             log.warning("ImageMagick size query failed")
             log.debug(
-                "`convert` exited with (status {}) when "
+                "`convert` exited with (status {.returncode}) when "
                 "getting size with command {}:\n{}",
-                exc.returncode,
+                exc,
                 cmd,
                 exc.output.strip(),
             )
@@ -441,8 +441,8 @@ def compare(
         convert_proc.wait()
         if convert_proc.returncode:
             log.debug(
-                "ImageMagick convert failed with status {}: {!r}",
-                convert_proc.returncode,
+                "ImageMagick convert failed with status {.returncode}: {!r}",
+                convert_proc,
                 convert_stderr,
             )
             return None
@@ -452,7 +452,7 @@ def compare(
         if compare_proc.returncode:
             if compare_proc.returncode != 1:
                 log.debug(
-                    "ImageMagick compare failed: {0}, {1}",
+                    "ImageMagick compare failed: {}, {}",
                     displayable_path(im2),
                     displayable_path(im1),
                 )
@@ -472,7 +472,7 @@ def compare(
             log.debug("IM output is not a number: {0!r}", out_str)
             return None
 
-        log.debug("ImageMagick compare score: {0}", phash_diff)
+        log.debug("ImageMagick compare score: {}", phash_diff)
         return phash_diff <= compare_threshold
 
     @property
@@ -523,7 +523,7 @@ def resize(
         from PIL import Image
 
         log.debug(
-            "artresizer: PIL resizing {0} to {1}",
+            "artresizer: PIL resizing {} to {}",
             displayable_path(path_in),
             displayable_path(path_out),
         )
@@ -552,7 +552,7 @@ def resize(
                 for i in range(5):
                     # 5 attempts is an arbitrary choice
                     filesize = os.stat(syspath(path_out)).st_size
-                    log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
+                    log.debug("PIL Pass {} : Output size: {}B", i, filesize)
                     if filesize <= max_filesize:
                         return path_out
                     # The relationship between filesize & quality will be
@@ -569,7 +569,7 @@ def resize(
                         progressive=False,
                     )
                 log.warning(
-                    "PIL Failed to resize file to below {0}B", max_filesize
+                    "PIL Failed to resize file to below {}B", max_filesize
                 )
                 return path_out
 
@@ -577,7 +577,7 @@ def resize(
                 return path_out
         except OSError:
             log.error(
-                "PIL cannot create thumbnail for '{0}'",
+                "PIL cannot create thumbnail for '{}'",
                 displayable_path(path_in),
             )
             return path_in
@@ -696,7 +696,7 @@ def __init__(self) -> None:
         for backend_cls in BACKEND_CLASSES:
             try:
                 self.local_method = backend_cls()
-                log.debug(f"artresizer: method is {self.local_method.NAME}")
+                log.debug("artresizer: method is {.local_method.NAME}", self)
                 break
             except LocalBackendNotAvailableError:
                 continue
diff --git a/beets/util/bluelet.py b/beets/util/bluelet.py
index b81b389e0d..3f3a88b1ee 100644
--- a/beets/util/bluelet.py
+++ b/beets/util/bluelet.py
@@ -559,7 +559,7 @@ def spawn(coro):
     and child coroutines run concurrently.
     """
     if not isinstance(coro, types.GeneratorType):
-        raise ValueError("%s is not a coroutine" % coro)
+        raise ValueError(f"{coro} is not a coroutine")
     return SpawnEvent(coro)
 
 
@@ -569,7 +569,7 @@ def call(coro):
     returns a value using end(), then this event returns that value.
     """
     if not isinstance(coro, types.GeneratorType):
-        raise ValueError("%s is not a coroutine" % coro)
+        raise ValueError(f"{coro} is not a coroutine")
     return DelegationEvent(coro)
 
 
diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py
index b0daefac28..5d85530a12 100644
--- a/beets/util/functemplate.py
+++ b/beets/util/functemplate.py
@@ -136,7 +136,7 @@ def __init__(self, ident, original):
         self.original = original
 
     def __repr__(self):
-        return "Symbol(%s)" % repr(self.ident)
+        return f"Symbol({self.ident!r})"
 
     def evaluate(self, env):
         """Evaluate the symbol in the environment, returning a Unicode
@@ -152,7 +152,7 @@ def evaluate(self, env):
     def translate(self):
         """Compile the variable lookup."""
         ident = self.ident
-        expr = ex_rvalue(VARIABLE_PREFIX + ident)
+        expr = ex_rvalue(f"{VARIABLE_PREFIX}{ident}")
         return [expr], {ident}, set()
 
 
@@ -165,9 +165,7 @@ def __init__(self, ident, args, original):
         self.original = original
 
     def __repr__(self):
-        return "Call({}, {}, {})".format(
-            repr(self.ident), repr(self.args), repr(self.original)
-        )
+        return f"Call({self.ident!r}, {self.args!r}, {self.original!r})"
 
     def evaluate(self, env):
         """Evaluate the function call in the environment, returning a
@@ -180,7 +178,7 @@ def evaluate(self, env):
             except Exception as exc:
                 # Function raised exception! Maybe inlining the name of
                 # the exception will help debug.
-                return "<%s>" % str(exc)
+                return f"<{exc}>"
             return str(out)
         else:
             return self.original
@@ -213,7 +211,7 @@ def translate(self):
                 )
             )
 
-        subexpr_call = ex_call(FUNCTION_PREFIX + self.ident, arg_exprs)
+        subexpr_call = ex_call(f"{FUNCTION_PREFIX}{self.ident}", arg_exprs)
         return [subexpr_call], varnames, funcnames
 
 
@@ -226,7 +224,7 @@ def __init__(self, parts):
         self.parts = parts
 
     def __repr__(self):
-        return "Expression(%s)" % (repr(self.parts))
+        return f"Expression({self.parts!r})"
 
     def evaluate(self, env):
         """Evaluate the entire expression in the environment, returning
@@ -298,9 +296,6 @@ def __init__(self, string, in_argument=False):
         GROUP_CLOSE,
         ESCAPE_CHAR,
     )
-    special_char_re = re.compile(
-        r"[%s]|\Z" % "".join(re.escape(c) for c in special_chars)
-    )
     escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
     terminator_chars = (GROUP_CLOSE,)
 
@@ -312,24 +307,18 @@ def parse_expression(self):
         """
         # Append comma (ARG_SEP) to the list of special characters only when
         # parsing function arguments.
-        extra_special_chars = ()
-        special_char_re = self.special_char_re
-        if self.in_argument:
-            extra_special_chars = (ARG_SEP,)
-            special_char_re = re.compile(
-                r"[%s]|\Z"
-                % "".join(
-                    re.escape(c)
-                    for c in self.special_chars + extra_special_chars
-                )
-            )
+        extra_special_chars = (ARG_SEP,) if self.in_argument else ()
+        special_chars = (*self.special_chars, *extra_special_chars)
+        special_char_re = re.compile(
+            rf"[{''.join(map(re.escape, special_chars))}]|\Z"
+        )
 
         text_parts = []
 
         while self.pos < len(self.string):
             char = self.string[self.pos]
 
-            if char not in self.special_chars + extra_special_chars:
+            if char not in special_chars:
                 # A non-special character. Skip to the next special
                 # character, treating the interstice as literal text.
                 next_pos = (
@@ -566,9 +555,9 @@ def translate(self):
 
         argnames = []
         for varname in varnames:
-            argnames.append(VARIABLE_PREFIX + varname)
+            argnames.append(f"{VARIABLE_PREFIX}{varname}")
         for funcname in funcnames:
-            argnames.append(FUNCTION_PREFIX + funcname)
+            argnames.append(f"{FUNCTION_PREFIX}{funcname}")
 
         func = compile_func(
             argnames,
@@ -578,9 +567,9 @@ def translate(self):
         def wrapper_func(values={}, functions={}):
             args = {}
             for varname in varnames:
-                args[VARIABLE_PREFIX + varname] = values[varname]
+                args[f"{VARIABLE_PREFIX}{varname}"] = values[varname]
             for funcname in funcnames:
-                args[FUNCTION_PREFIX + funcname] = functions[funcname]
+                args[f"{FUNCTION_PREFIX}{funcname}"] = functions[funcname]
             parts = func(**args)
             return "".join(parts)
 
diff --git a/beets/util/id_extractors.py b/beets/util/id_extractors.py
index 6cdb787d1e..f66f1690f6 100644
--- a/beets/util/id_extractors.py
+++ b/beets/util/id_extractors.py
@@ -58,7 +58,8 @@ def extract_release_id(source: str, id_: str) -> str | None:
         source_pattern = PATTERN_BY_SOURCE[source.lower()]
     except KeyError:
         log.debug(
-            f"Unknown source '{source}' for ID extraction. Returning id/url as-is."
+            "Unknown source '{}' for ID extraction. Returning id/url as-is.",
+            source,
         )
         return id_
 
diff --git a/beets/util/units.py b/beets/util/units.py
index d07d425462..f5fcb743b3 100644
--- a/beets/util/units.py
+++ b/beets/util/units.py
@@ -19,7 +19,7 @@ def human_seconds_short(interval):
     string.
     """
     interval = int(interval)
-    return "%i:%02i" % (interval // 60, interval % 60)
+    return f"{interval // 60}:{interval % 60:02d}"
 
 
 def human_bytes(size):
diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py
index c02a1c9236..62a2484823 100644
--- a/beetsplug/absubmit.py
+++ b/beetsplug/absubmit.py
@@ -42,9 +42,7 @@ def call(args):
     try:
         return util.command_output(args).stdout
     except subprocess.CalledProcessError as e:
-        raise ABSubmitError(
-            "{} exited with status {}".format(args[0], e.returncode)
-        )
+        raise ABSubmitError(f"{args[0]} exited with status {e.returncode}")
 
 
 class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
@@ -63,9 +61,7 @@ def __init__(self):
             # Explicit path to extractor
             if not os.path.isfile(self.extractor):
                 raise ui.UserError(
-                    "Extractor command does not exist: {0}.".format(
-                        self.extractor
-                    )
+                    f"Extractor command does not exist: {self.extractor}."
                 )
         else:
             # Implicit path to extractor, search for it in path
@@ -101,8 +97,8 @@ def __init__(self):
                     "with an HTTP scheme"
                 )
             elif base_url[-1] != "/":
-                base_url = base_url + "/"
-            self.url = base_url + "{mbid}/low-level"
+                base_url = f"{base_url}/"
+            self.url = f"{base_url}{{mbid}}/low-level"
 
     def commands(self):
         cmd = ui.Subcommand(
@@ -122,8 +118,10 @@ def commands(self):
             dest="pretend_fetch",
             action="store_true",
             default=False,
-            help="pretend to perform action, but show \
-only files which would be processed",
+            help=(
+                "pretend to perform action, but show only files which would be"
+                " processed"
+            ),
         )
         cmd.func = self.command
         return [cmd]
diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py
index 56ac0f6c55..92a1976a18 100644
--- a/beetsplug/acousticbrainz.py
+++ b/beetsplug/acousticbrainz.py
@@ -97,7 +97,7 @@ def __init__(self):
                     "with an HTTP scheme"
                 )
             elif self.base_url[-1] != "/":
-                self.base_url = self.base_url + "/"
+                self.base_url = f"{self.base_url}/"
 
         if self.config["auto"]:
             self.register_listener("import_task_files", self.import_task_files)
@@ -153,7 +153,7 @@ def _get_data(self, mbid):
             try:
                 data.update(res.json())
             except ValueError:
-                self._log.debug("Invalid Response: {}", res.text)
+                self._log.debug("Invalid Response: {.text}", res)
                 return {}
 
         return data
@@ -300,4 +300,4 @@ def _data_to_scheme_child(self, subdata, subscheme, composites):
 def _generate_urls(base_url, mbid):
     """Generates AcousticBrainz end point urls for given `mbid`."""
     for level in LEVELS:
-        yield base_url + mbid + level
+        yield f"{base_url}{mbid}{level}"
diff --git a/beetsplug/aura.py b/beetsplug/aura.py
index 53458d7ee8..7b75f31e57 100644
--- a/beetsplug/aura.py
+++ b/beetsplug/aura.py
@@ -236,14 +236,14 @@ def paginate(self, collection):
             # Not the last page so work out links.next url
             if not self.args:
                 # No existing arguments, so current page is 0
-                next_url = request.url + "?page=1"
+                next_url = f"{request.url}?page=1"
             elif not self.args.get("page", None):
                 # No existing page argument, so add one to the end
-                next_url = request.url + "&page=1"
+                next_url = f"{request.url}&page=1"
             else:
                 # Increment page token by 1
                 next_url = request.url.replace(
-                    f"page={page}", "page={}".format(page + 1)
+                    f"page={page}", f"page={page + 1}"
                 )
         # Get only the items in the page range
         data = [
@@ -427,9 +427,7 @@ def single_resource(self, track_id):
             return self.error(
                 "404 Not Found",
                 "No track with the requested id.",
-                "There is no track with an id of {} in the library.".format(
-                    track_id
-                ),
+                f"There is no track with an id of {track_id} in the library.",
             )
         return self.single_resource_document(
             self.get_resource_object(self.lib, track)
@@ -513,9 +511,7 @@ def single_resource(self, album_id):
             return self.error(
                 "404 Not Found",
                 "No album with the requested id.",
-                "There is no album with an id of {} in the library.".format(
-                    album_id
-                ),
+                f"There is no album with an id of {album_id} in the library.",
             )
         return self.single_resource_document(
             self.get_resource_object(self.lib, album)
@@ -600,9 +596,7 @@ def single_resource(self, artist_id):
             return self.error(
                 "404 Not Found",
                 "No artist with the requested id.",
-                "There is no artist with an id of {} in the library.".format(
-                    artist_id
-                ),
+                f"There is no artist with an id of {artist_id} in the library.",
             )
         return self.single_resource_document(artist_resource)
 
@@ -703,7 +697,7 @@ def get_resource_object(lib: Library, image_id):
         relationships = {}
         # Split id into [parent_type, parent_id, filename]
         id_split = image_id.split("-")
-        relationships[id_split[0] + "s"] = {
+        relationships[f"{id_split[0]}s"] = {
             "data": [{"type": id_split[0], "id": id_split[1]}]
         }
 
@@ -727,9 +721,7 @@ def single_resource(self, image_id):
             return self.error(
                 "404 Not Found",
                 "No image with the requested id.",
-                "There is no image with an id of {} in the library.".format(
-                    image_id
-                ),
+                f"There is no image with an id of {image_id} in the library.",
             )
         return self.single_resource_document(image_resource)
 
@@ -775,9 +767,7 @@ def audio_file(track_id):
         return AURADocument.error(
             "404 Not Found",
             "No track with the requested id.",
-            "There is no track with an id of {} in the library.".format(
-                track_id
-            ),
+            f"There is no track with an id of {track_id} in the library.",
         )
 
     path = os.fsdecode(track.path)
@@ -785,9 +775,8 @@ def audio_file(track_id):
         return AURADocument.error(
             "404 Not Found",
             "No audio file for the requested track.",
-            (
-                "There is no audio file for track {} at the expected location"
-            ).format(track_id),
+            f"There is no audio file for track {track_id} at the expected"
+            " location",
         )
 
     file_mimetype = guess_type(path)[0]
@@ -795,10 +784,8 @@ def audio_file(track_id):
         return AURADocument.error(
             "500 Internal Server Error",
             "Requested audio file has an unknown mimetype.",
-            (
-                "The audio file for track {} has an unknown mimetype. "
-                "Its file extension is {}."
-            ).format(track_id, path.split(".")[-1]),
+            f"The audio file for track {track_id} has an unknown mimetype. "
+            f"Its file extension is {path.split('.')[-1]}.",
         )
 
     # Check that the Accept header contains the file's mimetype
@@ -810,10 +797,8 @@ def audio_file(track_id):
         return AURADocument.error(
             "406 Not Acceptable",
             "Unsupported MIME type or bitrate parameter in Accept header.",
-            (
-                "The audio file for track {} is only available as {} and "
-                "bitrate parameters are not supported."
-            ).format(track_id, file_mimetype),
+            f"The audio file for track {track_id} is only available as"
+            f" {file_mimetype} and bitrate parameters are not supported.",
         )
 
     return send_file(
@@ -896,9 +881,7 @@ def image_file(image_id):
         return AURADocument.error(
             "404 Not Found",
             "No image with the requested id.",
-            "There is no image with an id of {} in the library".format(
-                image_id
-            ),
+            f"There is no image with an id of {image_id} in the library",
         )
     return send_file(img_path)
 
diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py
index 0511d960d1..070008be80 100644
--- a/beetsplug/badfiles.py
+++ b/beetsplug/badfiles.py
@@ -110,9 +110,7 @@ def check_item(self, item):
         self._log.debug("checking path: {}", dpath)
         if not os.path.exists(item.path):
             ui.print_(
-                "{}: file does not exist".format(
-                    ui.colorize("text_error", dpath)
-                )
+                f"{ui.colorize('text_error', dpath)}: file does not exist"
             )
 
         # Run the checker against the file if one is found
@@ -129,37 +127,32 @@ def check_item(self, item):
         except CheckerCommandError as e:
             if e.errno == errno.ENOENT:
                 self._log.error(
-                    "command not found: {} when validating file: {}",
-                    e.checker,
-                    e.path,
+                    "command not found: {0.checker} when validating file: {0.path}",
+                    e,
                 )
             else:
-                self._log.error("error invoking {}: {}", e.checker, e.msg)
+                self._log.error("error invoking {0.checker}: {0.msg}", e)
             return []
 
         error_lines = []
 
         if status > 0:
             error_lines.append(
-                "{}: checker exited with status {}".format(
-                    ui.colorize("text_error", dpath), status
-                )
+                f"{ui.colorize('text_error', dpath)}: checker exited with"
+                f" status {status}"
             )
             for line in output:
                 error_lines.append(f"  {line}")
 
         elif errors > 0:
             error_lines.append(
-                "{}: checker found {} errors or warnings".format(
-                    ui.colorize("text_warning", dpath), errors
-                )
+                f"{ui.colorize('text_warning', dpath)}: checker found"
+                f" {status} errors or warnings"
             )
             for line in output:
                 error_lines.append(f"  {line}")
         elif self.verbose:
-            error_lines.append(
-                "{}: ok".format(ui.colorize("text_success", dpath))
-            )
+            error_lines.append(f"{ui.colorize('text_success', dpath)}: ok")
 
         return error_lines
 
@@ -180,9 +173,8 @@ def on_import_task_start(self, task, session):
     def on_import_task_before_choice(self, task, session):
         if hasattr(task, "_badfiles_checks_failed"):
             ui.print_(
-                "{} one or more files failed checks:".format(
-                    ui.colorize("text_warning", "BAD")
-                )
+                f"{ui.colorize('text_warning', 'BAD')} one or more files failed"
+                " checks:"
             )
             for error in task._badfiles_checks_failed:
                 for error_line in error:
diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py
index 16e0dc896e..fa681ce6a6 100644
--- a/beetsplug/beatport.py
+++ b/beetsplug/beatport.py
@@ -110,7 +110,7 @@ def get_access_token(self, auth_data: str) -> tuple[str, str]:
         :returns:           OAuth resource owner key and secret as unicode
         """
         self.api.parse_authorization_response(
-            "https://beets.io/auth?" + auth_data
+            f"https://beets.io/auth?{auth_data}"
         )
         access_data = self.api.fetch_access_token(
             self._make_url("/identity/1/oauth/access-token")
@@ -200,8 +200,8 @@ def get_track(self, beatport_id: str) -> BeatportTrack:
     def _make_url(self, endpoint: str) -> str:
         """Get complete URL for a given API endpoint."""
         if not endpoint.startswith("/"):
-            endpoint = "/" + endpoint
-        return self._api_base + endpoint
+            endpoint = f"/{endpoint}"
+        return f"{self._api_base}{endpoint}"
 
     def _get(self, endpoint: str, **kwargs) -> list[JSONDict]:
         """Perform a GET request on a given API endpoint.
@@ -212,14 +212,10 @@ def _get(self, endpoint: str, **kwargs) -> list[JSONDict]:
         try:
             response = self.api.get(self._make_url(endpoint), params=kwargs)
         except Exception as e:
-            raise BeatportAPIError(
-                "Error connecting to Beatport API: {}".format(e)
-            )
+            raise BeatportAPIError(f"Error connecting to Beatport API: {e}")
         if not response:
             raise BeatportAPIError(
-                "Error {0.status_code} for '{0.request.path_url}".format(
-                    response
-                )
+                f"Error {response.status_code} for '{response.request.path_url}"
             )
         return response.json()["results"]
 
@@ -275,15 +271,14 @@ def __init__(self, data: JSONDict):
         self.genre = data.get("genre")
 
         if "slug" in data:
-            self.url = "https://beatport.com/release/{}/{}".format(
-                data["slug"], data["id"]
+            self.url = (
+                f"https://beatport.com/release/{data['slug']}/{data['id']}"
             )
 
     def __str__(self) -> str:
-        return "<BeatportRelease: {} - {} ({})>".format(
-            self.artists_str(),
-            self.name,
-            self.catalog_number,
+        return (
+            "<BeatportRelease: "
+            f"{self.artists_str()} - {self.name} ({self.catalog_number})>"
         )
 
 
@@ -311,9 +306,7 @@ def __init__(self, data: JSONDict):
             except ValueError:
                 pass
         if "slug" in data:
-            self.url = "https://beatport.com/track/{}/{}".format(
-                data["slug"], data["id"]
-            )
+            self.url = f"https://beatport.com/track/{data['slug']}/{data['id']}"
         self.track_number = data.get("trackNumber")
         self.bpm = data.get("bpm")
         self.initial_key = str((data.get("key") or {}).get("shortName"))
@@ -373,7 +366,7 @@ def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
         try:
             url = auth_client.get_authorize_url()
         except AUTH_ERRORS as e:
-            self._log.debug("authentication error: {0}", e)
+            self._log.debug("authentication error: {}", e)
             raise beets.ui.UserError("communication with Beatport failed")
 
         beets.ui.print_("To authenticate with Beatport, visit:")
@@ -384,11 +377,11 @@ def authenticate(self, c_key: str, c_secret: str) -> tuple[str, str]:
         try:
             token, secret = auth_client.get_access_token(data)
         except AUTH_ERRORS as e:
-            self._log.debug("authentication error: {0}", e)
+            self._log.debug("authentication error: {}", e)
             raise beets.ui.UserError("Beatport token request failed")
 
         # Save the token for later use.
-        self._log.debug("Beatport token {0}, secret {1}", token, secret)
+        self._log.debug("Beatport token {}, secret {}", token, secret)
         with open(self._tokenfile(), "w") as f:
             json.dump({"token": token, "secret": secret}, f)
 
@@ -412,7 +405,7 @@ def candidates(
         try:
             yield from self._get_releases(query)
         except BeatportAPIError as e:
-            self._log.debug("API Error: {0} (query: {1})", e, query)
+            self._log.debug("API Error: {} (query: {})", e, query)
             return
 
     def item_candidates(
@@ -422,14 +415,14 @@ def item_candidates(
         try:
             return self._get_tracks(query)
         except BeatportAPIError as e:
-            self._log.debug("API Error: {0} (query: {1})", e, query)
+            self._log.debug("API Error: {} (query: {})", e, query)
             return []
 
     def album_for_id(self, album_id: str):
         """Fetches a release by its Beatport ID and returns an AlbumInfo object
         or None if the query is not a valid ID or release is not found.
         """
-        self._log.debug("Searching for release {0}", album_id)
+        self._log.debug("Searching for release {}", album_id)
 
         if not (release_id := self._extract_id(album_id)):
             self._log.debug("Not a valid Beatport release ID.")
@@ -444,7 +437,7 @@ def track_for_id(self, track_id: str):
         """Fetches a track by its Beatport ID and returns a TrackInfo object
         or None if the track is not a valid Beatport ID or track is not found.
         """
-        self._log.debug("Searching for track {0}", track_id)
+        self._log.debug("Searching for track {}", track_id)
         # TODO: move to extractor
         match = re.search(r"(^|beatport\.com/track/.+/)(\d+)$", track_id)
         if not match:
diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py
index a2ad2835ce..aa70131509 100644
--- a/beetsplug/bpd/__init__.py
+++ b/beetsplug/bpd/__init__.py
@@ -52,7 +52,7 @@
 PROTOCOL_VERSION = "0.16.0"
 BUFSIZE = 1024
 
-HELLO = "OK MPD %s" % PROTOCOL_VERSION
+HELLO = f"OK MPD {PROTOCOL_VERSION}"
 CLIST_BEGIN = "command_list_begin"
 CLIST_VERBOSE_BEGIN = "command_list_ok_begin"
 CLIST_END = "command_list_end"
@@ -282,7 +282,7 @@ def _ctrl_send(self, message):
         if not self.ctrl_sock:
             self.ctrl_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
             self.ctrl_sock.connect((self.ctrl_host, self.ctrl_port))
-        self.ctrl_sock.sendall((message + "\n").encode("utf-8"))
+        self.ctrl_sock.sendall((f"{message}\n").encode("utf-8"))
 
     def _send_event(self, event):
         """Notify subscribed connections of an event."""
@@ -376,13 +376,13 @@ def cmd_commands(self, conn):
         if self.password and not conn.authenticated:
             # Not authenticated. Show limited list of commands.
             for cmd in SAFE_COMMANDS:
-                yield "command: " + cmd
+                yield f"command: {cmd}"
 
         else:
             # Authenticated. Show all commands.
             for func in dir(self):
                 if func.startswith("cmd_"):
-                    yield "command: " + func[4:]
+                    yield f"command: {func[4:]}"
 
     def cmd_notcommands(self, conn):
         """Lists all unavailable commands."""
@@ -392,7 +392,7 @@ def cmd_notcommands(self, conn):
                 if func.startswith("cmd_"):
                     cmd = func[4:]
                     if cmd not in SAFE_COMMANDS:
-                        yield "command: " + cmd
+                        yield f"command: {cmd}"
 
         else:
             # Authenticated. No commands are unavailable.
@@ -406,22 +406,22 @@ def cmd_status(self, conn):
         playlist, playlistlength, and xfade.
         """
         yield (
-            "repeat: " + str(int(self.repeat)),
-            "random: " + str(int(self.random)),
-            "consume: " + str(int(self.consume)),
-            "single: " + str(int(self.single)),
-            "playlist: " + str(self.playlist_version),
-            "playlistlength: " + str(len(self.playlist)),
-            "mixrampdb: " + str(self.mixrampdb),
+            f"repeat: {int(self.repeat)}",
+            f"random: {int(self.random)}",
+            f"consume: {int(self.consume)}",
+            f"single: {int(self.single)}",
+            f"playlist: {self.playlist_version}",
+            f"playlistlength: {len(self.playlist)}",
+            f"mixrampdb: {self.mixrampdb}",
         )
 
         if self.volume > 0:
-            yield "volume: " + str(self.volume)
+            yield f"volume: {self.volume}"
 
         if not math.isnan(self.mixrampdelay):
-            yield "mixrampdelay: " + str(self.mixrampdelay)
+            yield f"mixrampdelay: {self.mixrampdelay}"
         if self.crossfade > 0:
-            yield "xfade: " + str(self.crossfade)
+            yield f"xfade: {self.crossfade}"
 
         if self.current_index == -1:
             state = "stop"
@@ -429,20 +429,20 @@ def cmd_status(self, conn):
             state = "pause"
         else:
             state = "play"
-        yield "state: " + state
+        yield f"state: {state}"
 
         if self.current_index != -1:  # i.e., paused or playing
             current_id = self._item_id(self.playlist[self.current_index])
-            yield "song: " + str(self.current_index)
-            yield "songid: " + str(current_id)
+            yield f"song: {self.current_index}"
+            yield f"songid: {current_id}"
             if len(self.playlist) > self.current_index + 1:
                 # If there's a next song, report its index too.
                 next_id = self._item_id(self.playlist[self.current_index + 1])
-                yield "nextsong: " + str(self.current_index + 1)
-                yield "nextsongid: " + str(next_id)
+                yield f"nextsong: {self.current_index + 1}"
+                yield f"nextsongid: {next_id}"
 
         if self.error:
-            yield "error: " + self.error
+            yield f"error: {self.error}"
 
     def cmd_clearerror(self, conn):
         """Removes the persistent error state of the server. This
@@ -522,7 +522,7 @@ def cmd_replay_gain_mode(self, conn, mode):
 
     def cmd_replay_gain_status(self, conn):
         """Get the replaygain mode."""
-        yield "replay_gain_mode: " + str(self.replay_gain_mode)
+        yield f"replay_gain_mode: {self.replay_gain_mode}"
 
     def cmd_clear(self, conn):
         """Clear the playlist."""
@@ -643,8 +643,8 @@ def cmd_plchangesposid(self, conn, version):
         Also a dummy implementation.
         """
         for idx, track in enumerate(self.playlist):
-            yield "cpos: " + str(idx)
-            yield "Id: " + str(track.id)
+            yield f"cpos: {idx}"
+            yield f"Id: {track.id}"
 
     def cmd_currentsong(self, conn):
         """Sends information about the currently-playing song."""
@@ -759,11 +759,11 @@ def __init__(self, server, sock):
         """Create a new connection for the accepted socket `client`."""
         self.server = server
         self.sock = sock
-        self.address = "{}:{}".format(*sock.sock.getpeername())
+        self.address = ":".join(map(str, sock.sock.getpeername()))
 
     def debug(self, message, kind=" "):
         """Log a debug message about this connection."""
-        self.server._log.debug("{}[{}]: {}", kind, self.address, message)
+        self.server._log.debug("{}[{.address}]: {}", kind, self, message)
 
     def run(self):
         pass
@@ -899,9 +899,7 @@ def run(self):
                     return
                 except BPDIdleError as e:
                     self.idle_subscriptions = e.subsystems
-                    self.debug(
-                        "awaiting: {}".format(" ".join(e.subsystems)), kind="z"
-                    )
+                    self.debug(f"awaiting: {' '.join(e.subsystems)}", kind="z")
                 yield bluelet.call(self.server.dispatch_events())
 
 
@@ -913,7 +911,7 @@ def __init__(self, server, sock):
         super().__init__(server, sock)
 
     def debug(self, message, kind=" "):
-        self.server._log.debug("CTRL {}[{}]: {}", kind, self.address, message)
+        self.server._log.debug("CTRL {}[{.address}]: {}", kind, self, message)
 
     def run(self):
         """Listen for control commands and delegate to `ctrl_*` methods."""
@@ -933,7 +931,7 @@ def run(self):
                 func = command.delegate("ctrl_", self)
                 yield bluelet.call(func(*command.args))
             except (AttributeError, TypeError) as e:
-                yield self.send("ERROR: {}".format(e.args[0]))
+                yield self.send(f"ERROR: {e.args[0]}")
             except Exception:
                 yield self.send(
                     ["ERROR: server error", traceback.format_exc().rstrip()]
@@ -992,7 +990,7 @@ def delegate(self, prefix, target, extra_args=0):
         of arguments.
         """
         # Attempt to get correct command function.
-        func_name = prefix + self.name
+        func_name = f"{prefix}{self.name}"
         if not hasattr(target, func_name):
             raise AttributeError(f'unknown command "{self.name}"')
         func = getattr(target, func_name)
@@ -1011,7 +1009,7 @@ def delegate(self, prefix, target, extra_args=0):
         # If the command accepts a variable number of arguments skip the check.
         if wrong_num and not argspec.varargs:
             raise TypeError(
-                'wrong number of arguments for "{}"'.format(self.name),
+                f'wrong number of arguments for "{self.name}"',
                 self.name,
             )
 
@@ -1110,10 +1108,8 @@ def __init__(self, library, host, port, password, ctrl_port, log):
         self.lib = library
         self.player = gstplayer.GstPlayer(self.play_finished)
         self.cmd_update(None)
-        log.info("Server ready and listening on {}:{}".format(host, port))
-        log.debug(
-            "Listening for control signals on {}:{}".format(host, ctrl_port)
-        )
+        log.info("Server ready and listening on {}:{}", host, port)
+        log.debug("Listening for control signals on {}:{}", host, ctrl_port)
 
     def run(self):
         self.player.run()
@@ -1128,23 +1124,21 @@ def play_finished(self):
 
     def _item_info(self, item):
         info_lines = [
-            "file: " + as_string(item.destination(relative_to_libdir=True)),
-            "Time: " + str(int(item.length)),
-            "duration: " + f"{item.length:.3f}",
-            "Id: " + str(item.id),
+            f"file: {as_string(item.destination(relative_to_libdir=True))}",
+            f"Time: {int(item.length)}",
+            "duration: {item.length:.3f}",
+            f"Id: {item.id}",
         ]
 
         try:
             pos = self._id_to_index(item.id)
-            info_lines.append("Pos: " + str(pos))
+            info_lines.append(f"Pos: {pos}")
         except ArgumentNotFoundError:
             # Don't include position if not in playlist.
             pass
 
         for tagtype, field in self.tagtype_map.items():
-            info_lines.append(
-                "{}: {}".format(tagtype, str(getattr(item, field)))
-            )
+            info_lines.append(f"{tagtype}: {getattr(item, field)}")
 
         return info_lines
 
@@ -1207,7 +1201,7 @@ def _resolve_path(self, path):
 
     def _path_join(self, p1, p2):
         """Smashes together two BPD paths."""
-        out = p1 + "/" + p2
+        out = f"{p1}/{p2}"
         return out.replace("//", "/").replace("//", "/")
 
     def cmd_lsinfo(self, conn, path="/"):
@@ -1225,7 +1219,7 @@ def cmd_lsinfo(self, conn, path="/"):
                 if dirpath.startswith("/"):
                     # Strip leading slash (libmpc rejects this).
                     dirpath = dirpath[1:]
-                yield "directory: %s" % dirpath
+                yield f"directory: {dirpath}"
 
     def _listall(self, basepath, node, info=False):
         """Helper function for recursive listing. If info, show
@@ -1237,7 +1231,7 @@ def _listall(self, basepath, node, info=False):
                 item = self.lib.get_item(node)
                 yield self._item_info(item)
             else:
-                yield "file: " + basepath
+                yield f"file: {basepath}"
         else:
             # List a directory. Recurse into both directories and files.
             for name, itemid in sorted(node.files.items()):
@@ -1246,7 +1240,7 @@ def _listall(self, basepath, node, info=False):
                 yield from self._listall(newpath, itemid, info)
             for name, subdir in sorted(node.dirs.items()):
                 newpath = self._path_join(basepath, name)
-                yield "directory: " + newpath
+                yield f"directory: {newpath}"
                 yield from self._listall(newpath, subdir, info)
 
     def cmd_listall(self, conn, path="/"):
@@ -1280,7 +1274,7 @@ def _add(self, path, send_id=False):
         for item in self._all_items(self._resolve_path(path)):
             self.playlist.append(item)
             if send_id:
-                yield "Id: " + str(item.id)
+                yield f"Id: {item.id}"
         self.playlist_version += 1
         self._send_event("playlist")
 
@@ -1302,20 +1296,13 @@ def cmd_status(self, conn):
             item = self.playlist[self.current_index]
 
             yield (
-                "bitrate: " + str(item.bitrate / 1000),
-                "audio: {}:{}:{}".format(
-                    str(item.samplerate),
-                    str(item.bitdepth),
-                    str(item.channels),
-                ),
+                f"bitrate: {item.bitrate / 1000}",
+                f"audio: {item.samplerate}:{item.bitdepth}:{item.channels}",
             )
 
             (pos, total) = self.player.time()
             yield (
-                "time: {}:{}".format(
-                    str(int(pos)),
-                    str(int(total)),
-                ),
+                f"time: {int(pos)}:{int(total)}",
                 "elapsed: " + f"{pos:.3f}",
                 "duration: " + f"{total:.3f}",
             )
@@ -1335,13 +1322,13 @@ def cmd_stats(self, conn):
             artists, albums, songs, totaltime = tx.query(statement)[0]
 
         yield (
-            "artists: " + str(artists),
-            "albums: " + str(albums),
-            "songs: " + str(songs),
-            "uptime: " + str(int(time.time() - self.startup_time)),
-            "playtime: " + "0",  # Missing.
-            "db_playtime: " + str(int(totaltime)),
-            "db_update: " + str(int(self.updated_time)),
+            f"artists: {artists}",
+            f"albums: {albums}",
+            f"songs: {songs}",
+            f"uptime: {int(time.time() - self.startup_time)}",
+            "playtime: 0",  # Missing.
+            f"db_playtime: {int(totaltime)}",
+            f"db_update: {int(self.updated_time)}",
         )
 
     def cmd_decoders(self, conn):
@@ -1383,7 +1370,7 @@ def cmd_tagtypes(self, conn):
         searching.
         """
         for tag in self.tagtype_map:
-            yield "tagtype: " + tag
+            yield f"tagtype: {tag}"
 
     def _tagtype_lookup(self, tag):
         """Uses `tagtype_map` to look up the beets column name for an
@@ -1458,12 +1445,9 @@ def cmd_list(self, conn, show_tag, *kv):
 
         clause, subvals = query.clause()
         statement = (
-            "SELECT DISTINCT "
-            + show_key
-            + " FROM items WHERE "
-            + clause
-            + " ORDER BY "
-            + show_key
+            f"SELECT DISTINCT {show_key}"
+            f" FROM items WHERE {clause}"
+            f" ORDER BY {show_key}"
         )
         self._log.debug(statement)
         with self.lib.transaction() as tx:
@@ -1473,7 +1457,7 @@ def cmd_list(self, conn, show_tag, *kv):
             if not row[0]:
                 # Skip any empty values of the field.
                 continue
-            yield show_tag_canon + ": " + str(row[0])
+            yield f"{show_tag_canon}: {row[0]}"
 
     def cmd_count(self, conn, tag, value):
         """Returns the number and total time of songs matching the
@@ -1487,8 +1471,8 @@ def cmd_count(self, conn, tag, value):
         ):
             songs += 1
             playtime += item.length
-        yield "songs: " + str(songs)
-        yield "playtime: " + str(int(playtime))
+        yield f"songs: {songs}"
+        yield f"playtime: {int(playtime)}"
 
     # Persistent playlist manipulation. In MPD this is an optional feature so
     # these dummy implementations match MPD's behaviour with the feature off.
diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py
index 03fb179aa2..fa23f2b0eb 100644
--- a/beetsplug/bpd/gstplayer.py
+++ b/beetsplug/bpd/gstplayer.py
@@ -129,7 +129,7 @@ def play_file(self, path):
         self.player.set_state(Gst.State.NULL)
         if isinstance(path, str):
             path = path.encode("utf-8")
-        uri = "file://" + urllib.parse.quote(path)
+        uri = f"file://{urllib.parse.quote(path)}"
         self.player.set_property("uri", uri)
         self.player.set_state(Gst.State.PLAYING)
         self.playing = True
diff --git a/beetsplug/bpm.py b/beetsplug/bpm.py
index 145986a957..d49963b728 100644
--- a/beetsplug/bpm.py
+++ b/beetsplug/bpm.py
@@ -73,12 +73,12 @@ def get_bpm(self, items, write=False):
 
         item = items[0]
         if item["bpm"]:
-            self._log.info("Found bpm {0}", item["bpm"])
+            self._log.info("Found bpm {}", item["bpm"])
             if not overwrite:
                 return
 
         self._log.info(
-            "Press Enter {0} times to the rhythm or Ctrl-D to exit",
+            "Press Enter {} times to the rhythm or Ctrl-D to exit",
             self.config["max_strokes"].get(int),
         )
         new_bpm = bpm(self.config["max_strokes"].get(int))
@@ -86,4 +86,4 @@ def get_bpm(self, items, write=False):
         if write:
             item.try_write()
         item.store()
-        self._log.info("Added new bpm {0}", item["bpm"])
+        self._log.info("Added new bpm {}", item["bpm"])
diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py
index ccd781b28b..9ae6d47d5f 100644
--- a/beetsplug/bpsync.py
+++ b/beetsplug/bpsync.py
@@ -82,8 +82,8 @@ def singletons(self, lib, query, move, pretend, write):
 
             if not self.is_beatport_track(item):
                 self._log.info(
-                    "Skipping non-{} singleton: {}",
-                    self.beatport_plugin.data_source,
+                    "Skipping non-{.beatport_plugin.data_source} singleton: {}",
+                    self,
                     item,
                 )
                 continue
@@ -107,8 +107,8 @@ def get_album_tracks(self, album):
             return False
         if not album.mb_albumid.isnumeric():
             self._log.info(
-                "Skipping album with invalid {} ID: {}",
-                self.beatport_plugin.data_source,
+                "Skipping album with invalid {.beatport_plugin.data_source} ID: {}",
+                self,
                 album,
             )
             return False
@@ -117,8 +117,8 @@ def get_album_tracks(self, album):
             return items
         if not all(self.is_beatport_track(item) for item in items):
             self._log.info(
-                "Skipping non-{} release: {}",
-                self.beatport_plugin.data_source,
+                "Skipping non-{.beatport_plugin.data_source} release: {}",
+                self,
                 album,
             )
             return False
@@ -139,9 +139,7 @@ def albums(self, lib, query, move, pretend, write):
             albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid)
             if not albuminfo:
                 self._log.info(
-                    "Release ID {} not found for album {}",
-                    album.mb_albumid,
-                    album,
+                    "Release ID {0.mb_albumid} not found for album {0}", album
                 )
                 continue
 
diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py
index 9246539fcf..40369f74ab 100644
--- a/beetsplug/bucket.py
+++ b/beetsplug/bucket.py
@@ -41,7 +41,7 @@ def span_from_str(span_str):
     def normalize_year(d, yearfrom):
         """Convert string to a 4 digits year"""
         if yearfrom < 100:
-            raise BucketError("%d must be expressed on 4 digits" % yearfrom)
+            raise BucketError(f"{yearfrom} must be expressed on 4 digits")
 
         # if two digits only, pick closest year that ends by these two
         # digits starting from yearfrom
@@ -55,14 +55,13 @@ def normalize_year(d, yearfrom):
     years = [int(x) for x in re.findall(r"\d+", span_str)]
     if not years:
         raise ui.UserError(
-            "invalid range defined for year bucket '%s': no "
-            "year found" % span_str
+            f"invalid range defined for year bucket {span_str!r}: no year found"
         )
     try:
         years = [normalize_year(x, years[0]) for x in years]
     except BucketError as exc:
         raise ui.UserError(
-            "invalid range defined for year bucket '%s': %s" % (span_str, exc)
+            f"invalid range defined for year bucket {span_str!r}: {exc}"
         )
 
     res = {"from": years[0], "str": span_str}
@@ -125,22 +124,19 @@ def str2fmt(s):
         "fromnchars": len(m.group("fromyear")),
         "tonchars": len(m.group("toyear")),
     }
-    res["fmt"] = "{}%s{}{}{}".format(
-        m.group("bef"),
-        m.group("sep"),
-        "%s" if res["tonchars"] else "",
-        m.group("after"),
+    res["fmt"] = (
+        f"{m['bef']}{{}}{m['sep']}{'{}' if res['tonchars'] else ''}{m['after']}"
     )
     return res
 
 
 def format_span(fmt, yearfrom, yearto, fromnchars, tonchars):
     """Return a span string representation."""
-    args = str(yearfrom)[-fromnchars:]
+    args = [str(yearfrom)[-fromnchars:]]
     if tonchars:
-        args = (str(yearfrom)[-fromnchars:], str(yearto)[-tonchars:])
+        args.append(str(yearto)[-tonchars:])
 
-    return fmt % args
+    return fmt.format(*args)
 
 
 def extract_modes(spans):
@@ -169,14 +165,12 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs):
             else:
                 raise ui.UserError(
                     "invalid range defined for alpha bucket "
-                    "'%s': no alphanumeric character found" % elem
+                    f"'{elem}': no alphanumeric character found"
                 )
             spans.append(
                 re.compile(
-                    "^["
-                    + ASCII_DIGITS[begin_index : end_index + 1]
-                    + ASCII_DIGITS[begin_index : end_index + 1].upper()
-                    + "]"
+                    rf"^[{ASCII_DIGITS[begin_index : end_index + 1]}]",
+                    re.IGNORECASE,
                 )
             )
     return spans
diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py
index 3b31382b7d..192310fb84 100644
--- a/beetsplug/chroma.py
+++ b/beetsplug/chroma.py
@@ -90,7 +90,7 @@ def acoustid_match(log, path):
         duration, fp = acoustid.fingerprint_file(util.syspath(path))
     except acoustid.FingerprintGenerationError as exc:
         log.error(
-            "fingerprinting of {0} failed: {1}",
+            "fingerprinting of {} failed: {}",
             util.displayable_path(repr(path)),
             exc,
         )
@@ -103,12 +103,12 @@ def acoustid_match(log, path):
         )
     except acoustid.AcoustidError as exc:
         log.debug(
-            "fingerprint matching {0} failed: {1}",
+            "fingerprint matching {} failed: {}",
             util.displayable_path(repr(path)),
             exc,
         )
         return None
-    log.debug("chroma: fingerprinted {0}", util.displayable_path(repr(path)))
+    log.debug("chroma: fingerprinted {}", util.displayable_path(repr(path)))
 
     # Ensure the response is usable and parse it.
     if res["status"] != "ok" or not res.get("results"):
@@ -146,7 +146,7 @@ def acoustid_match(log, path):
     release_ids = [rel["id"] for rel in releases]
 
     log.debug(
-        "matched recordings {0} on releases {1}", recording_ids, release_ids
+        "matched recordings {} on releases {}", recording_ids, release_ids
     )
     _matches[path] = recording_ids, release_ids
 
@@ -211,7 +211,7 @@ def candidates(self, items, artist, album, va_likely):
             if album:
                 albums.append(album)
 
-        self._log.debug("acoustid album candidates: {0}", len(albums))
+        self._log.debug("acoustid album candidates: {}", len(albums))
         return albums
 
     def item_candidates(self, item, artist, title) -> Iterable[TrackInfo]:
@@ -224,7 +224,7 @@ def item_candidates(self, item, artist, title) -> Iterable[TrackInfo]:
             track = self.mb.track_for_id(recording_id)
             if track:
                 tracks.append(track)
-        self._log.debug("acoustid item candidates: {0}", len(tracks))
+        self._log.debug("acoustid item candidates: {}", len(tracks))
         return tracks
 
     def album_for_id(self, *args, **kwargs):
@@ -292,11 +292,11 @@ def submit_items(log, userkey, items, chunksize=64):
 
     def submit_chunk():
         """Submit the current accumulated fingerprint data."""
-        log.info("submitting {0} fingerprints", len(data))
+        log.info("submitting {} fingerprints", len(data))
         try:
             acoustid.submit(API_KEY, userkey, data, timeout=10)
         except acoustid.AcoustidError as exc:
-            log.warning("acoustid submission error: {0}", exc)
+            log.warning("acoustid submission error: {}", exc)
         del data[:]
 
     for item in items:
@@ -343,31 +343,23 @@ def fingerprint_item(log, item, write=False):
     """
     # Get a fingerprint and length for this track.
     if not item.length:
-        log.info("{0}: no duration available", util.displayable_path(item.path))
+        log.info("{.filepath}: no duration available", item)
     elif item.acoustid_fingerprint:
         if write:
-            log.info(
-                "{0}: fingerprint exists, skipping",
-                util.displayable_path(item.path),
-            )
+            log.info("{.filepath}: fingerprint exists, skipping", item)
         else:
-            log.info(
-                "{0}: using existing fingerprint",
-                util.displayable_path(item.path),
-            )
+            log.info("{.filepath}: using existing fingerprint", item)
         return item.acoustid_fingerprint
     else:
-        log.info("{0}: fingerprinting", util.displayable_path(item.path))
+        log.info("{.filepath}: fingerprinting", item)
         try:
             _, fp = acoustid.fingerprint_file(util.syspath(item.path))
             item.acoustid_fingerprint = fp.decode()
             if write:
-                log.info(
-                    "{0}: writing fingerprint", util.displayable_path(item.path)
-                )
+                log.info("{.filepath}: writing fingerprint", item)
                 item.try_write()
             if item._db:
                 item.store()
             return item.acoustid_fingerprint
         except acoustid.FingerprintGenerationError as exc:
-            log.info("fingerprint generation failed: {0}", exc)
+            log.info("fingerprint generation failed: {}", exc)
diff --git a/beetsplug/convert.py b/beetsplug/convert.py
index c4df9ab574..e9db3592e1 100644
--- a/beetsplug/convert.py
+++ b/beetsplug/convert.py
@@ -64,9 +64,7 @@ def get_format(fmt=None):
         command = format_info["command"]
         extension = format_info.get("extension", fmt)
     except KeyError:
-        raise ui.UserError(
-            'convert: format {} needs the "command" field'.format(fmt)
-        )
+        raise ui.UserError(f'convert: format {fmt} needs the "command" field')
     except ConfigTypeError:
         command = config["convert"]["formats"][fmt].get(str)
         extension = fmt
@@ -77,8 +75,8 @@ def get_format(fmt=None):
         command = config["convert"]["command"].as_str()
     elif "opts" in keys:
         # Undocumented option for backwards compatibility with < 1.3.1.
-        command = "ffmpeg -i $source -y {} $dest".format(
-            config["convert"]["opts"].as_str()
+        command = (
+            f"ffmpeg -i $source -y {config['convert']['opts'].as_str()} $dest"
         )
     if "extension" in keys:
         extension = config["convert"]["extension"].as_str()
@@ -125,18 +123,25 @@ def __init__(self):
                 "id3v23": "inherit",
                 "formats": {
                     "aac": {
-                        "command": "ffmpeg -i $source -y -vn -acodec aac "
-                        "-aq 1 $dest",
+                        "command": (
+                            "ffmpeg -i $source -y -vn -acodec aac -aq 1 $dest"
+                        ),
                         "extension": "m4a",
                     },
                     "alac": {
-                        "command": "ffmpeg -i $source -y -vn -acodec alac $dest",
+                        "command": (
+                            "ffmpeg -i $source -y -vn -acodec alac $dest"
+                        ),
                         "extension": "m4a",
                     },
                     "flac": "ffmpeg -i $source -y -vn -acodec flac $dest",
                     "mp3": "ffmpeg -i $source -y -vn -aq 2 $dest",
-                    "opus": "ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest",
-                    "ogg": "ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest",
+                    "opus": (
+                        "ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest"
+                    ),
+                    "ogg": (
+                        "ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest"
+                    ),
                     "wma": "ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest",
                 },
                 "max_bitrate": None,
@@ -171,16 +176,17 @@ def commands(self):
             "--threads",
             action="store",
             type="int",
-            help="change the number of threads, \
-                              defaults to maximum available processors",
+            help=(
+                "change the number of threads, defaults to maximum available"
+                " processors"
+            ),
         )
         cmd.parser.add_option(
             "-k",
             "--keep-new",
             action="store_true",
             dest="keep_new",
-            help="keep only the converted \
-                              and move the old files",
+            help="keep only the converted and move the old files",
         )
         cmd.parser.add_option(
             "-d", "--dest", action="store", help="set the destination directory"
@@ -204,16 +210,16 @@ def commands(self):
             "--link",
             action="store_true",
             dest="link",
-            help="symlink files that do not \
-                              need transcoding.",
+            help="symlink files that do not need transcoding.",
         )
         cmd.parser.add_option(
             "-H",
             "--hardlink",
             action="store_true",
             dest="hardlink",
-            help="hardlink files that do not \
-                              need transcoding. Overrides --link.",
+            help=(
+                "hardlink files that do not need transcoding. Overrides --link."
+            ),
         )
         cmd.parser.add_option(
             "-m",
@@ -282,7 +288,7 @@ def encode(self, command, source, dest, pretend=False):
         quiet = self.config["quiet"].get(bool)
 
         if not quiet and not pretend:
-            self._log.info("Encoding {0}", util.displayable_path(source))
+            self._log.info("Encoding {}", util.displayable_path(source))
 
         command = os.fsdecode(command)
         source = os.fsdecode(source)
@@ -301,7 +307,7 @@ def encode(self, command, source, dest, pretend=False):
             encode_cmd.append(os.fsdecode(args[i]))
 
         if pretend:
-            self._log.info("{0}", " ".join(args))
+            self._log.info("{}", " ".join(args))
             return
 
         try:
@@ -309,26 +315,25 @@ def encode(self, command, source, dest, pretend=False):
         except subprocess.CalledProcessError as exc:
             # Something went wrong (probably Ctrl+C), remove temporary files
             self._log.info(
-                "Encoding {0} failed. Cleaning up...",
+                "Encoding {} failed. Cleaning up...",
                 util.displayable_path(source),
             )
             self._log.debug(
-                "Command {0} exited with status {1}: {2}",
+                "Command {0} exited with status {1.returncode}: {1.output}",
                 args,
-                exc.returncode,
-                exc.output,
+                exc,
             )
             util.remove(dest)
             util.prune_dirs(os.path.dirname(dest))
             raise
         except OSError as exc:
             raise ui.UserError(
-                "convert: couldn't invoke '{}': {}".format(" ".join(args), exc)
+                f"convert: couldn't invoke {' '.join(args)!r}: {exc}"
             )
 
         if not quiet and not pretend:
             self._log.info(
-                "Finished encoding {0}", util.displayable_path(source)
+                "Finished encoding {}", util.displayable_path(source)
             )
 
     def convert_item(
@@ -356,7 +361,7 @@ def convert_item(
             try:
                 mediafile.MediaFile(util.syspath(item.path))
             except mediafile.UnreadableFileError as exc:
-                self._log.error("Could not open file to convert: {0}", exc)
+                self._log.error("Could not open file to convert: {}", exc)
                 continue
 
             # When keeping the new file in the library, we first move the
@@ -382,21 +387,20 @@ def convert_item(
 
             if os.path.exists(util.syspath(dest)):
                 self._log.info(
-                    "Skipping {0} (target file exists)",
-                    util.displayable_path(item.path),
+                    "Skipping {.filepath} (target file exists)", item
                 )
                 continue
 
             if keep_new:
                 if pretend:
                     self._log.info(
-                        "mv {0} {1}",
-                        util.displayable_path(item.path),
+                        "mv {.filepath} {}",
+                        item,
                         util.displayable_path(original),
                     )
                 else:
                     self._log.info(
-                        "Moving to {0}", util.displayable_path(original)
+                        "Moving to {}", util.displayable_path(original)
                     )
                     util.move(item.path, original)
 
@@ -412,10 +416,10 @@ def convert_item(
                     msg = "ln" if hardlink else ("ln -s" if link else "cp")
 
                     self._log.info(
-                        "{2} {0} {1}",
+                        "{} {} {}",
+                        msg,
                         util.displayable_path(original),
                         util.displayable_path(converted),
-                        msg,
                     )
                 else:
                     # No transcoding necessary.
@@ -425,9 +429,7 @@ def convert_item(
                         else ("Linking" if link else "Copying")
                     )
 
-                    self._log.info(
-                        "{1} {0}", util.displayable_path(item.path), msg
-                    )
+                    self._log.info("{} {.filepath}", msg, item)
 
                     if hardlink:
                         util.hardlink(original, converted)
@@ -458,8 +460,7 @@ def convert_item(
                 if album and album.artpath:
                     maxwidth = self._get_art_resize(album.artpath)
                     self._log.debug(
-                        "embedding album art from {}",
-                        util.displayable_path(album.artpath),
+                        "embedding album art from {.art_filepath}", album
                     )
                     art.embed_item(
                         self._log,
@@ -517,8 +518,7 @@ def copy_album_art(
 
         if os.path.exists(util.syspath(dest)):
             self._log.info(
-                "Skipping {0} (target file exists)",
-                util.displayable_path(album.artpath),
+                "Skipping {.art_filepath} (target file exists)", album
             )
             return
 
@@ -528,8 +528,8 @@ def copy_album_art(
         # Either copy or resize (while copying) the image.
         if maxwidth is not None:
             self._log.info(
-                "Resizing cover art from {0} to {1}",
-                util.displayable_path(album.artpath),
+                "Resizing cover art from {.art_filepath} to {}",
+                album,
                 util.displayable_path(dest),
             )
             if not pretend:
@@ -539,10 +539,10 @@ def copy_album_art(
                 msg = "ln" if hardlink else ("ln -s" if link else "cp")
 
                 self._log.info(
-                    "{2} {0} {1}",
-                    util.displayable_path(album.artpath),
-                    util.displayable_path(dest),
+                    "{} {.art_filepath} {}",
                     msg,
+                    album,
+                    util.displayable_path(dest),
                 )
             else:
                 msg = (
@@ -552,10 +552,10 @@ def copy_album_art(
                 )
 
                 self._log.info(
-                    "{2} cover art from {0} to {1}",
-                    util.displayable_path(album.artpath),
-                    util.displayable_path(dest),
+                    "{} cover art from {.art_filepath} to {}",
                     msg,
+                    album,
+                    util.displayable_path(dest),
                 )
                 if hardlink:
                     util.hardlink(album.artpath, dest)
@@ -616,7 +616,7 @@ def convert_func(self, lib, opts, args):
             # Playlist paths are understood as relative to the dest directory.
             pl_normpath = util.normpath(playlist)
             pl_dir = os.path.dirname(pl_normpath)
-            self._log.info("Creating playlist file {0}", pl_normpath)
+            self._log.info("Creating playlist file {}", pl_normpath)
             # Generates a list of paths to media files, ensures the paths are
             # relative to the playlist's location and translates the unicode
             # strings we get from item.destination to bytes.
@@ -644,7 +644,7 @@ def convert_on_import(self, lib, item):
             tmpdir = self.config["tmpdir"].get()
             if tmpdir:
                 tmpdir = os.fsdecode(util.bytestring_path(tmpdir))
-            fd, dest = tempfile.mkstemp(os.fsdecode(b"." + ext), dir=tmpdir)
+            fd, dest = tempfile.mkstemp(f".{os.fsdecode(ext)}", dir=tmpdir)
             os.close(fd)
             dest = util.bytestring_path(dest)
             _temp_files.append(dest)  # Delete the transcode later.
@@ -666,7 +666,7 @@ def convert_on_import(self, lib, item):
             if self.config["delete_originals"]:
                 self._log.log(
                     logging.DEBUG if self.config["quiet"] else logging.INFO,
-                    "Removing original file {0}",
+                    "Removing original file {}",
                     source_path,
                 )
                 util.remove(source_path, False)
diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py
index c8602b5e89..e427b08b1c 100644
--- a/beetsplug/deezer.py
+++ b/beetsplug/deezer.py
@@ -96,7 +96,7 @@ def album_for_id(self, album_id: str) -> AlbumInfo | None:
                 f"Invalid `release_date` returned by {self.data_source} API: "
                 f"{release_date!r}"
             )
-        tracks_obj = self.fetch_data(self.album_url + deezer_id + "/tracks")
+        tracks_obj = self.fetch_data(f"{self.album_url}{deezer_id}/tracks")
         if tracks_obj is None:
             return None
         try:
@@ -169,7 +169,7 @@ def track_for_id(self, track_id: str) -> None | TrackInfo:
         # the track's disc).
         if not (
             album_tracks_obj := self.fetch_data(
-                self.album_url + str(track_data["album"]["id"]) + "/tracks"
+                f"{self.album_url}{track_data['album']['id']}/tracks"
             )
         ):
             return None
@@ -241,26 +241,26 @@ def _search_api(
         query = self._construct_search_query(
             query_string=query_string, filters=filters
         )
-        self._log.debug(f"Searching {self.data_source} for '{query}'")
+        self._log.debug("Searching {.data_source} for '{}'", self, query)
         try:
             response = requests.get(
-                self.search_url + query_type,
+                f"{self.search_url}{query_type}",
                 params={"q": query},
                 timeout=10,
             )
             response.raise_for_status()
         except requests.exceptions.RequestException as e:
             self._log.error(
-                "Error fetching data from {} API\n Error: {}",
-                self.data_source,
+                "Error fetching data from {.data_source} API\n Error: {}",
+                self,
                 e,
             )
             return ()
         response_data: Sequence[IDResponse] = response.json().get("data", [])
         self._log.debug(
-            "Found {} result(s) from {} for '{}'",
+            "Found {} result(s) from {.data_source} for '{}'",
             len(response_data),
-            self.data_source,
+            self,
             query,
         )
         return response_data
diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py
index ac7421c5ff..bf41cf38d8 100644
--- a/beetsplug/discogs.py
+++ b/beetsplug/discogs.py
@@ -145,7 +145,7 @@ def authenticate(self, c_key, c_secret):
         try:
             _, _, url = auth_client.get_authorize_url()
         except CONNECTION_ERRORS as e:
-            self._log.debug("connection error: {0}", e)
+            self._log.debug("connection error: {}", e)
             raise beets.ui.UserError("communication with Discogs failed")
 
         beets.ui.print_("To authenticate with Discogs, visit:")
@@ -158,11 +158,11 @@ def authenticate(self, c_key, c_secret):
         except DiscogsAPIError:
             raise beets.ui.UserError("Discogs authorization failed")
         except CONNECTION_ERRORS as e:
-            self._log.debug("connection error: {0}", e)
+            self._log.debug("connection error: {}", e)
             raise beets.ui.UserError("Discogs token request failed")
 
         # Save the token for later use.
-        self._log.debug("Discogs token {0}, secret {1}", token, secret)
+        self._log.debug("Discogs token {}, secret {}", token, secret)
         with open(self._tokenfile(), "w") as f:
             json.dump({"token": token, "secret": secret}, f)
 
@@ -202,7 +202,7 @@ def album_for_id(self, album_id: str) -> AlbumInfo | None:
         """Fetches an album by its Discogs ID and returns an AlbumInfo object
         or None if the album is not found.
         """
-        self._log.debug("Searching for release {0}", album_id)
+        self._log.debug("Searching for release {}", album_id)
 
         discogs_id = self._extract_id(album_id)
 
@@ -216,7 +216,7 @@ def album_for_id(self, album_id: str) -> AlbumInfo | None:
         except DiscogsAPIError as e:
             if e.status_code != 404:
                 self._log.debug(
-                    "API Error: {0} (query: {1})",
+                    "API Error: {} (query: {})",
                     e,
                     result.data["resource_url"],
                 )
@@ -266,7 +266,7 @@ def get_master_year(self, master_id: str) -> int | None:
         """Fetches a master release given its Discogs ID and returns its year
         or None if the master release is not found.
         """
-        self._log.debug("Getting master release {0}", master_id)
+        self._log.debug("Getting master release {}", master_id)
         result = Master(self.discogs_client, {"id": master_id})
 
         try:
@@ -274,7 +274,7 @@ def get_master_year(self, master_id: str) -> int | None:
         except DiscogsAPIError as e:
             if e.status_code != 404:
                 self._log.debug(
-                    "API Error: {0} (query: {1})",
+                    "API Error: {} (query: {})",
                     e,
                     result.data["resource_url"],
                 )
@@ -385,7 +385,7 @@ def get_album_info(self, result):
                 track.artist_id = artist_id
             # Discogs does not have track IDs. Invent our own IDs as proposed
             # in #2336.
-            track.track_id = str(album_id) + "-" + track.track_alt
+            track.track_id = f"{album_id}-{track.track_alt}"
             track.data_url = data_url
             track.data_source = "Discogs"
 
@@ -552,7 +552,7 @@ def add_merged_subtracks(tracklist, subtracks):
             idx, medium_idx, sub_idx = self.get_track_index(
                 subtracks[0]["position"]
             )
-            position = "{}{}".format(idx or "", medium_idx or "")
+            position = f"{idx or ''}{medium_idx or ''}"
 
             if tracklist and not tracklist[-1]["position"]:
                 # Assume the previous index track contains the track title.
@@ -574,8 +574,8 @@ def add_merged_subtracks(tracklist, subtracks):
                     # option is set
                     if self.config["index_tracks"]:
                         for subtrack in subtracks:
-                            subtrack["title"] = "{}: {}".format(
-                                index_track["title"], subtrack["title"]
+                            subtrack["title"] = (
+                                f"{index_track['title']}: {subtrack['title']}"
                             )
                     tracklist.extend(subtracks)
             else:
diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py
index ea7abaaff9..904e192628 100644
--- a/beetsplug/duplicates.py
+++ b/beetsplug/duplicates.py
@@ -150,7 +150,7 @@ def _dup(lib, opts, args):
             count = self.config["count"].get(bool)
             delete = self.config["delete"].get(bool)
             remove = self.config["remove"].get(bool)
-            fmt = self.config["format"].get(str)
+            fmt_tmpl = self.config["format"].get(str)
             full = self.config["full"].get(bool)
             keys = self.config["keys"].as_str_seq()
             merge = self.config["merge"].get(bool)
@@ -175,15 +175,14 @@ def _dup(lib, opts, args):
                 return
 
             if path:
-                fmt = "$path"
+                fmt_tmpl = "$path"
 
             # Default format string for count mode.
-            if count and not fmt:
+            if count and not fmt_tmpl:
                 if album:
-                    fmt = "$albumartist - $album"
+                    fmt_tmpl = "$albumartist - $album"
                 else:
-                    fmt = "$albumartist - $album - $title"
-                fmt += ": {0}"
+                    fmt_tmpl = "$albumartist - $album - $title"
 
             if checksum:
                 for i in items:
@@ -207,7 +206,7 @@ def _dup(lib, opts, args):
                             delete=delete,
                             remove=remove,
                             tag=tag,
-                            fmt=fmt.format(obj_count),
+                            fmt=f"{fmt_tmpl}: {obj_count}",
                         )
 
         self._command.func = _dup
@@ -255,28 +254,24 @@ def _checksum(self, item, prog):
         checksum = getattr(item, key, False)
         if not checksum:
             self._log.debug(
-                "key {0} on item {1} not cached:computing checksum",
+                "key {} on item {.filepath} not cached:computing checksum",
                 key,
-                displayable_path(item.path),
+                item,
             )
             try:
                 checksum = command_output(args).stdout
                 setattr(item, key, checksum)
                 item.store()
                 self._log.debug(
-                    "computed checksum for {0} using {1}", item.title, key
+                    "computed checksum for {.title} using {}", item, key
                 )
             except subprocess.CalledProcessError as e:
-                self._log.debug(
-                    "failed to checksum {0}: {1}",
-                    displayable_path(item.path),
-                    e,
-                )
+                self._log.debug("failed to checksum {.filepath}: {}", item, e)
         else:
             self._log.debug(
-                "key {0} on item {1} cached:not computing checksum",
+                "key {} on item {.filepath} cached:not computing checksum",
                 key,
-                displayable_path(item.path),
+                item,
             )
         return key, checksum
 
@@ -294,15 +289,15 @@ def _group_by(self, objs, keys, strict):
             values = [v for v in values if v not in (None, "")]
             if strict and len(values) < len(keys):
                 self._log.debug(
-                    "some keys {0} on item {1} are null or empty: skipping",
+                    "some keys {} on item {.filepath} are null or empty: skipping",
                     keys,
-                    displayable_path(obj.path),
+                    obj,
                 )
             elif not strict and not len(values):
                 self._log.debug(
-                    "all keys {0} on item {1} are null or empty: skipping",
+                    "all keys {} on item {.filepath} are null or empty: skipping",
                     keys,
-                    displayable_path(obj.path),
+                    obj,
                 )
             else:
                 key = tuple(values)
@@ -360,11 +355,11 @@ def _merge_items(self, objs):
                     value = getattr(o, f, None)
                     if value:
                         self._log.debug(
-                            "key {0} on item {1} is null "
-                            "or empty: setting from item {2}",
+                            "key {} on item {} is null "
+                            "or empty: setting from item {.filepath}",
                             f,
                             displayable_path(objs[0].path),
-                            displayable_path(o.path),
+                            o,
                         )
                         setattr(objs[0], f, value)
                         objs[0].store()
@@ -384,11 +379,11 @@ def _merge_albums(self, objs):
                     missing.album_id = objs[0].id
                     missing.add(i._db)
                     self._log.debug(
-                        "item {0} missing from album {1}:"
-                        " merging from {2} into {3}",
+                        "item {} missing from album {}:"
+                        " merging from {.filepath} into {}",
                         missing,
                         objs[0],
-                        displayable_path(o.path),
+                        o,
                         displayable_path(missing.destination()),
                     )
                     missing.move(operation=MoveOperation.COPY)
diff --git a/beetsplug/edit.py b/beetsplug/edit.py
index 52387c3140..f6fadefd0c 100644
--- a/beetsplug/edit.py
+++ b/beetsplug/edit.py
@@ -46,9 +46,7 @@ def edit(filename, log):
     try:
         subprocess.call(cmd)
     except OSError as exc:
-        raise ui.UserError(
-            "could not run editor command {!r}: {}".format(cmd[0], exc)
-        )
+        raise ui.UserError(f"could not run editor command {cmd[0]!r}: {exc}")
 
 
 def dump(arg):
@@ -71,9 +69,7 @@ def load(s):
         for d in yaml.safe_load_all(s):
             if not isinstance(d, dict):
                 raise ParseError(
-                    "each entry must be a dictionary; found {}".format(
-                        type(d).__name__
-                    )
+                    f"each entry must be a dictionary; found {type(d).__name__}"
                 )
 
             # Convert all keys to strings. They started out as strings,
diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py
index 8df3c3c056..1a59e4f9c2 100644
--- a/beetsplug/embedart.py
+++ b/beetsplug/embedart.py
@@ -35,8 +35,9 @@ def _confirm(objs, album):
     to items).
     """
     noun = "album" if album else "file"
-    prompt = "Modify artwork for {} {}{} (Y/n)?".format(
-        len(objs), noun, "s" if len(objs) > 1 else ""
+    prompt = (
+        "Modify artwork for"
+        f" {len(objs)} {noun}{'s' if len(objs) > 1 else ''} (Y/n)?"
     )
 
     # Show all the items or albums.
@@ -110,9 +111,7 @@ def embed_func(lib, opts, args):
                 imagepath = normpath(opts.file)
                 if not os.path.isfile(syspath(imagepath)):
                     raise ui.UserError(
-                        "image file {} not found".format(
-                            displayable_path(imagepath)
-                        )
+                        f"image file {displayable_path(imagepath)} not found"
                     )
 
                 items = lib.items(args)
@@ -137,7 +136,7 @@ def embed_func(lib, opts, args):
                     response = requests.get(opts.url, timeout=5)
                     response.raise_for_status()
                 except requests.exceptions.RequestException as e:
-                    self._log.error("{}".format(e))
+                    self._log.error("{}", e)
                     return
                 extension = guess_extension(response.headers["Content-Type"])
                 if extension is None:
@@ -149,7 +148,7 @@ def embed_func(lib, opts, args):
                     with open(tempimg, "wb") as f:
                         f.write(response.content)
                 except Exception as e:
-                    self._log.error("Unable to save image: {}".format(e))
+                    self._log.error("Unable to save image: {}", e)
                     return
                 items = lib.items(args)
                 # Confirm with user.
@@ -274,7 +273,7 @@ def remove_artfile(self, album):
         """
         if self.config["remove_art_file"] and album.artpath:
             if os.path.isfile(syspath(album.artpath)):
-                self._log.debug("Removing album art file for {0}", album)
+                self._log.debug("Removing album art file for {}", album)
                 os.remove(syspath(album.artpath))
                 album.artpath = None
                 album.store()
diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py
index c696f39f38..25f3ed8b3d 100644
--- a/beetsplug/embyupdate.py
+++ b/beetsplug/embyupdate.py
@@ -38,9 +38,7 @@ def api_url(host, port, endpoint):
         hostname_list.insert(0, "http://")
         hostname = "".join(hostname_list)
 
-    joined = urljoin(
-        "{hostname}:{port}".format(hostname=hostname, port=port), endpoint
-    )
+    joined = urljoin(f"{hostname}:{port}", endpoint)
 
     scheme, netloc, path, query_string, fragment = urlsplit(joined)
     query_params = parse_qs(query_string)
@@ -81,12 +79,12 @@ def create_headers(user_id, token=None):
     headers = {}
 
     authorization = (
-        'MediaBrowser UserId="{user_id}", '
+        f'MediaBrowser UserId="{user_id}", '
         'Client="other", '
         'Device="beets", '
         'DeviceId="beets", '
         'Version="0.0.0"'
-    ).format(user_id=user_id)
+    )
 
     headers["x-emby-authorization"] = authorization
 
@@ -186,7 +184,7 @@ def update(self, lib):
             # Get user information from the Emby API.
             user = get_user(host, port, username)
             if not user:
-                self._log.warning(f"User {username} could not be found.")
+                self._log.warning("User {} could not be found.", username)
                 return
             userid = user[0]["Id"]
 
@@ -198,7 +196,7 @@ def update(self, lib):
             # Get authentication token.
             token = get_token(host, port, headers, auth_data)
             if not token:
-                self._log.warning("Could not get token for user {0}", username)
+                self._log.warning("Could not get token for user {}", username)
                 return
 
         # Recreate headers with a token.
diff --git a/beetsplug/export.py b/beetsplug/export.py
index 05ca3f24ad..e6c2b88c7a 100644
--- a/beetsplug/export.py
+++ b/beetsplug/export.py
@@ -150,7 +150,7 @@ def run(self, lib, opts, args):
             try:
                 data, item = data_emitter(included_keys or "*")
             except (mediafile.UnreadableFileError, OSError) as ex:
-                self._log.error("cannot read file: {0}", ex)
+                self._log.error("cannot read file: {}", ex)
                 continue
 
             for key, value in data.items():
diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py
index e1ec5aa09a..54c065da40 100644
--- a/beetsplug/fetchart.py
+++ b/beetsplug/fetchart.py
@@ -133,7 +133,7 @@ def _validate(
         # get_size returns None if no local imaging backend is available
         if not self.size:
             self.size = ArtResizer.shared.get_size(self.path)
-        self._log.debug("image size: {}", self.size)
+        self._log.debug("image size: {.size}", self)
 
         if not self.size:
             self._log.warning(
@@ -151,7 +151,7 @@ def _validate(
         # Check minimum dimension.
         if plugin.minwidth and self.size[0] < plugin.minwidth:
             self._log.debug(
-                "image too small ({} < {})", self.size[0], plugin.minwidth
+                "image too small ({} < {.minwidth})", self.size[0], plugin
             )
             return ImageAction.BAD
 
@@ -162,10 +162,10 @@ def _validate(
                 if edge_diff > plugin.margin_px:
                     self._log.debug(
                         "image is not close enough to being "
-                        "square, ({} - {} > {})",
+                        "square, ({} - {} > {.margin_px})",
                         long_edge,
                         short_edge,
-                        plugin.margin_px,
+                        plugin,
                     )
                     return ImageAction.BAD
             elif plugin.margin_percent:
@@ -190,7 +190,7 @@ def _validate(
         downscale = False
         if plugin.maxwidth and self.size[0] > plugin.maxwidth:
             self._log.debug(
-                "image needs rescaling ({} > {})", self.size[0], plugin.maxwidth
+                "image needs rescaling ({} > {.maxwidth})", self.size[0], plugin
             )
             downscale = True
 
@@ -200,9 +200,9 @@ def _validate(
             filesize = os.stat(syspath(self.path)).st_size
             if filesize > plugin.max_filesize:
                 self._log.debug(
-                    "image needs resizing ({}B > {}B)",
+                    "image needs resizing ({}B > {.max_filesize}B)",
                     filesize,
-                    plugin.max_filesize,
+                    plugin,
                 )
                 downsize = True
 
@@ -213,9 +213,9 @@ def _validate(
             reformat = fmt != plugin.cover_format
             if reformat:
                 self._log.debug(
-                    "image needs reformatting: {} -> {}",
+                    "image needs reformatting: {} -> {.cover_format}",
                     fmt,
-                    plugin.cover_format,
+                    plugin,
                 )
 
         skip_check_for = skip_check_for or []
@@ -329,7 +329,7 @@ def _logged_get(log: Logger, *args, **kwargs) -> requests.Response:
             prepped.url, {}, None, None, None
         )
         send_kwargs.update(settings)
-        log.debug("{}: {}", message, prepped.url)
+        log.debug("{}: {.url}", message, prepped)
         return s.send(prepped, **send_kwargs)
 
 
@@ -542,14 +542,14 @@ def get_image_urls(
             try:
                 response = self.request(url)
             except requests.RequestException:
-                self._log.debug("{}: error receiving response", self.NAME)
+                self._log.debug("{.NAME}: error receiving response", self)
                 return
 
             try:
                 data = response.json()
             except ValueError:
                 self._log.debug(
-                    "{}: error loading response: {}", self.NAME, response.text
+                    "{.NAME}: error loading response: {.text}", self, response
                 )
                 return
 
@@ -593,7 +593,7 @@ def get_image_urls(
 class Amazon(RemoteArtSource):
     NAME = "Amazon"
     ID = "amazon"
-    URL = "https://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg"
+    URL = "https://images.amazon.com/images/P/{}.{:02d}.LZZZZZZZ.jpg"
     INDICES = (1, 2)
 
     def get(
@@ -606,7 +606,7 @@ def get(
         if album.asin:
             for index in self.INDICES:
                 yield self._candidate(
-                    url=self.URL % (album.asin, index),
+                    url=self.URL.format(album.asin, index),
                     match=MetadataMatch.EXACT,
                 )
 
@@ -629,7 +629,7 @@ def get(
         # Get the page from albumart.org.
         try:
             resp = self.request(self.URL, params={"asin": album.asin})
-            self._log.debug("scraped art URL: {}", resp.url)
+            self._log.debug("scraped art URL: {.url}", resp)
         except requests.RequestException:
             self._log.debug("error scraping art page")
             return
@@ -682,7 +682,7 @@ def get(
         """
         if not (album.albumartist and album.album):
             return
-        search_string = (album.albumartist + "," + album.album).encode("utf-8")
+        search_string = f"{album.albumartist},{album.album}".encode("utf-8")
 
         try:
             response = self.request(
@@ -702,7 +702,7 @@ def get(
         try:
             data = response.json()
         except ValueError:
-            self._log.debug("google: error loading response: {}", response.text)
+            self._log.debug("google: error loading response: {.text}", response)
             return
 
         if "error" in data:
@@ -723,7 +723,7 @@ class FanartTV(RemoteArtSource):
     NAME = "fanart.tv"
     ID = "fanarttv"
     API_URL = "https://webservice.fanart.tv/v3/"
-    API_ALBUMS = API_URL + "music/albums/"
+    API_ALBUMS = f"{API_URL}music/albums/"
     PROJECT_KEY = "61a7d0ab4e67162b7a0c7c35915cd48e"
 
     def __init__(self, *args, **kwargs):
@@ -750,7 +750,7 @@ def get(
 
         try:
             response = self.request(
-                self.API_ALBUMS + album.mb_releasegroupid,
+                f"{self.API_ALBUMS}{album.mb_releasegroupid}",
                 headers={
                     "api-key": self.PROJECT_KEY,
                     "client-key": self.client_key,
@@ -764,7 +764,7 @@ def get(
             data = response.json()
         except ValueError:
             self._log.debug(
-                "fanart.tv: error loading response: {}", response.text
+                "fanart.tv: error loading response: {.text}", response
             )
             return
 
@@ -820,7 +820,7 @@ def get(
             return
 
         payload = {
-            "term": album.albumartist + " " + album.album,
+            "term": f"{album.albumartist} {album.album}",
             "entity": "album",
             "media": "music",
             "limit": 200,
@@ -947,14 +947,14 @@ def get(
             data = dbpedia_response.json()
             results = data["results"]["bindings"]
             if results:
-                cover_filename = "File:" + results[0]["coverFilename"]["value"]
+                cover_filename = f"File:{results[0]['coverFilename']['value']}"
                 page_id = results[0]["pageId"]["value"]
             else:
                 self._log.debug("wikipedia: album not found on dbpedia")
         except (ValueError, KeyError, IndexError):
             self._log.debug(
-                "wikipedia: error scraping dbpedia response: {}",
-                dbpedia_response.text,
+                "wikipedia: error scraping dbpedia response: {.text}",
+                dbpedia_response,
             )
 
         # Ensure we have a filename before attempting to query wikipedia
@@ -996,7 +996,7 @@ def get(
                 results = data["query"]["pages"][page_id]["images"]
                 for result in results:
                     if re.match(
-                        re.escape(lpart) + r".*?\." + re.escape(rpart),
+                        rf"{re.escape(lpart)}.*?\.{re.escape(rpart)}",
                         result["title"],
                     ):
                         cover_filename = result["title"]
@@ -1179,7 +1179,7 @@ def get(
             if "error" in data:
                 if data["error"] == 6:
                     self._log.debug(
-                        "lastfm: no results for {}", album.mb_albumid
+                        "lastfm: no results for {.mb_albumid}", album
                     )
                 else:
                     self._log.error(
@@ -1200,7 +1200,7 @@ def get(
                             url=images[size], size=self.SIZES[size]
                         )
         except ValueError:
-            self._log.debug("lastfm: error loading response: {}", response.text)
+            self._log.debug("lastfm: error loading response: {.text}", response)
             return
 
 
@@ -1227,7 +1227,7 @@ def get(
         paths: None | Sequence[bytes],
     ) -> Iterator[Candidate]:
         try:
-            url = self.SPOTIFY_ALBUM_URL + album.items().get().spotify_album_id
+            url = f"{self.SPOTIFY_ALBUM_URL}{album.items().get().spotify_album_id}"
         except AttributeError:
             self._log.debug("Fetchart: no Spotify album ID found")
             return
@@ -1244,7 +1244,7 @@ def get(
             soup = BeautifulSoup(html, "html.parser")
         except ValueError:
             self._log.debug(
-                "Spotify: error loading response: {}", response.text
+                "Spotify: error loading response: {.text}", response
             )
             return
 
@@ -1541,9 +1541,7 @@ def art_for_album(
                         out = candidate
                         assert out.path is not None  # help mypy
                         self._log.debug(
-                            "using {0.LOC} image {1}",
-                            source,
-                            util.displayable_path(out.path),
+                            "using {.LOC} image {.path}", source, out
                         )
                         break
                     # Remove temporary files for invalid candidates.
@@ -1576,7 +1574,7 @@ def batch_fetch_art(
                     message = ui.colorize(
                         "text_highlight_minor", "has album art"
                     )
-                    self._log.info("{0}: {1}", album, message)
+                    self._log.info("{}: {}", album, message)
             else:
                 # In ordinary invocations, look for images on the
                 # filesystem. When forcing, however, always go to the Web
@@ -1589,4 +1587,4 @@ def batch_fetch_art(
                     message = ui.colorize("text_success", "found album art")
                 else:
                     message = ui.colorize("text_error", "no art found")
-                self._log.info("{0}: {1}", album, message)
+                self._log.info("{}: {}", album, message)
diff --git a/beetsplug/fish.py b/beetsplug/fish.py
index 4cf9b60a16..b1518f1c40 100644
--- a/beetsplug/fish.py
+++ b/beetsplug/fish.py
@@ -89,8 +89,9 @@ def commands(self):
             "-o",
             "--output",
             default="~/.config/fish/completions/beet.fish",
-            help="where to save the script. default: "
-            "~/.config/fish/completions",
+            help=(
+                "where to save the script. default: ~/.config/fish/completions"
+            ),
         )
         return [cmd]
 
@@ -122,23 +123,13 @@ def run(self, lib, opts, args):
             for name in names:
                 cmd_names_help.append((name, cmd.help))
         # Concatenate the string
-        totstring = HEAD + "\n"
+        totstring = f"{HEAD}\n"
         totstring += get_cmds_list([name[0] for name in cmd_names_help])
         totstring += "" if nobasicfields else get_standard_fields(fields)
         totstring += get_extravalues(lib, extravalues) if extravalues else ""
-        totstring += (
-            "\n"
-            + "# ====== {} =====".format("setup basic beet completion")
-            + "\n" * 2
-        )
+        totstring += "\n# ====== setup basic beet completion =====\n\n"
         totstring += get_basic_beet_options()
-        totstring += (
-            "\n"
-            + "# ====== {} =====".format(
-                "setup field completion for subcommands"
-            )
-            + "\n"
-        )
+        totstring += "\n# ====== setup field completion for subcommands =====\n"
         totstring += get_subcommands(cmd_names_help, nobasicfields, extravalues)
         # Set up completion for all the command options
         totstring += get_all_commands(beetcmds)
@@ -150,23 +141,19 @@ def run(self, lib, opts, args):
 def _escape(name):
     # Escape ? in fish
     if name == "?":
-        name = "\\" + name
+        name = f"\\{name}"
     return name
 
 
 def get_cmds_list(cmds_names):
     # Make a list of all Beets core & plugin commands
-    substr = ""
-    substr += "set CMDS " + " ".join(cmds_names) + ("\n" * 2)
-    return substr
+    return f"set CMDS {' '.join(cmds_names)}\n\n"
 
 
 def get_standard_fields(fields):
     # Make a list of album/track fields and append with ':'
-    fields = (field + ":" for field in fields)
-    substr = ""
-    substr += "set FIELDS " + " ".join(fields) + ("\n" * 2)
-    return substr
+    fields = (f"{field}:" for field in fields)
+    return f"set FIELDS {' '.join(fields)}\n\n"
 
 
 def get_extravalues(lib, extravalues):
@@ -175,14 +162,8 @@ def get_extravalues(lib, extravalues):
     word = ""
     values_set = get_set_of_values_for_field(lib, extravalues)
     for fld in extravalues:
-        extraname = fld.upper() + "S"
-        word += (
-            "set  "
-            + extraname
-            + " "
-            + " ".join(sorted(values_set[fld]))
-            + ("\n" * 2)
-        )
+        extraname = f"{fld.upper()}S"
+        word += f"set  {extraname} {' '.join(sorted(values_set[fld]))}\n\n"
     return word
 
 
@@ -226,35 +207,29 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
     for cmdname, cmdhelp in cmd_name_and_help:
         cmdname = _escape(cmdname)
 
-        word += (
-            "\n"
-            + "# ------ {} -------".format("fieldsetups for  " + cmdname)
-            + "\n"
-        )
+        word += f"\n# ------ fieldsetups for {cmdname} -------\n"
         word += BL_NEED2.format(
-            ("-a " + cmdname), ("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))
+            f"-a {cmdname}", f"-f -d {wrap(clean_whitespace(cmdhelp))}"
         )
 
         if nobasicfields is False:
             word += BL_USE3.format(
                 cmdname,
-                ("-a " + wrap("$FIELDS")),
-                ("-f " + "-d " + wrap("fieldname")),
+                f"-a {wrap('$FIELDS')}",
+                f"-f -d {wrap('fieldname')}",
             )
 
         if extravalues:
             for f in extravalues:
-                setvar = wrap("$" + f.upper() + "S")
-                word += (
-                    " ".join(
-                        BL_EXTRA3.format(
-                            (cmdname + " " + f + ":"),
-                            ("-f " + "-A " + "-a " + setvar),
-                            ("-d " + wrap(f)),
-                        ).split()
-                    )
-                    + "\n"
+                setvar = wrap(f"${f.upper()}S")
+                word += " ".join(
+                    BL_EXTRA3.format(
+                        f"{cmdname} {f}:",
+                        f"-f -A -a {setvar}",
+                        f"-d {wrap(f)}",
+                    ).split()
                 )
+                word += "\n"
     return word
 
 
@@ -267,59 +242,44 @@ def get_all_commands(beetcmds):
         for name in names:
             name = _escape(name)
 
-            word += "\n"
-            word += (
-                ("\n" * 2)
-                + "# ====== {} =====".format("completions for  " + name)
-                + "\n"
-            )
+            word += f"\n\n\n# ====== completions for {name} =====\n"
 
             for option in cmd.parser._get_all_options()[1:]:
                 cmd_l = (
-                    (" -l " + option._long_opts[0].replace("--", ""))
+                    f" -l {option._long_opts[0].replace('--', '')}"
                     if option._long_opts
                     else ""
                 )
                 cmd_s = (
-                    (" -s " + option._short_opts[0].replace("-", ""))
+                    f" -s {option._short_opts[0].replace('-', '')}"
                     if option._short_opts
                     else ""
                 )
                 cmd_need_arg = " -r " if option.nargs in [1] else ""
                 cmd_helpstr = (
-                    (" -d " + wrap(" ".join(option.help.split())))
+                    f" -d {wrap(' '.join(option.help.split()))}"
                     if option.help
                     else ""
                 )
                 cmd_arglist = (
-                    (" -a " + wrap(" ".join(option.choices)))
+                    f" -a {wrap(' '.join(option.choices))}"
                     if option.choices
                     else ""
                 )
 
-                word += (
-                    " ".join(
-                        BL_USE3.format(
-                            name,
-                            (
-                                cmd_need_arg
-                                + cmd_s
-                                + cmd_l
-                                + " -f "
-                                + cmd_arglist
-                            ),
-                            cmd_helpstr,
-                        ).split()
-                    )
-                    + "\n"
+                word += " ".join(
+                    BL_USE3.format(
+                        name,
+                        f"{cmd_need_arg}{cmd_s}{cmd_l} -f {cmd_arglist}",
+                        cmd_helpstr,
+                    ).split()
                 )
+                word += "\n"
 
-            word = word + " ".join(
-                BL_USE3.format(
-                    name,
-                    ("-s " + "h " + "-l " + "help" + " -f "),
-                    ("-d " + wrap("print help") + "\n"),
-                ).split()
+            word = word + BL_USE3.format(
+                name,
+                "-s h -l help -f",
+                f"-d {wrap('print help')}",
             )
     return word
 
@@ -332,9 +292,9 @@ def clean_whitespace(word):
 def wrap(word):
     # Need " or ' around strings but watch out if they're in the string
     sptoken = '"'
-    if ('"') in word and ("'") in word:
+    if '"' in word and ("'") in word:
         word.replace('"', sptoken)
-        return '"' + word + '"'
+        return f'"{word}"'
 
     tok = '"' if "'" in word else "'"
-    return tok + word + tok
+    return f"{tok}{word}{tok}"
diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py
index 103e829016..5e8b338c7b 100644
--- a/beetsplug/fromfilename.py
+++ b/beetsplug/fromfilename.py
@@ -112,7 +112,7 @@ def apply_matches(d, log):
         for item in d:
             if not item.artist:
                 item.artist = artist
-                log.info("Artist replaced with: {}".format(item.artist))
+                log.info("Artist replaced with: {.artist}", item)
 
     # No artist field: remaining field is the title.
     else:
@@ -122,11 +122,11 @@ def apply_matches(d, log):
     for item in d:
         if bad_title(item.title):
             item.title = str(d[item][title_field])
-            log.info("Title replaced with: {}".format(item.title))
+            log.info("Title replaced with: {.title}", item)
 
         if "track" in d[item] and item.track == 0:
             item.track = int(d[item]["track"])
-            log.info("Track replaced with: {}".format(item.track))
+            log.info("Track replaced with: {.track}", item)
 
 
 # Plugin structure and hook into import process.
diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py
index 150f230aad..f0f0880999 100644
--- a/beetsplug/ftintitle.py
+++ b/beetsplug/ftintitle.py
@@ -20,7 +20,6 @@
 from typing import TYPE_CHECKING
 
 from beets import plugins, ui
-from beets.util import displayable_path
 
 if TYPE_CHECKING:
     from beets.importer import ImportSession, ImportTask
@@ -90,7 +89,7 @@ def __init__(self) -> None:
             {
                 "auto": True,
                 "drop": False,
-                "format": "feat. {0}",
+                "format": "feat. {}",
                 "keep_in_artist": False,
             }
         )
@@ -151,10 +150,10 @@ def update_metadata(
         # In case the artist is kept, do not update the artist fields.
         if keep_in_artist_field:
             self._log.info(
-                "artist: {0} (Not changing due to keep_in_artist)", item.artist
+                "artist: {.artist} (Not changing due to keep_in_artist)", item
             )
         else:
-            self._log.info("artist: {0} -> {1}", item.artist, item.albumartist)
+            self._log.info("artist: {0.artist} -> {0.albumartist}", item)
             item.artist = item.albumartist
 
         if item.artist_sort:
@@ -167,7 +166,7 @@ def update_metadata(
             feat_format = self.config["format"].as_str()
             new_format = feat_format.format(feat_part)
             new_title = f"{item.title} {new_format}"
-            self._log.info("title: {0} -> {1}", item.title, new_title)
+            self._log.info("title: {.title} -> {}", item, new_title)
             item.title = new_title
 
     def ft_in_title(
@@ -195,7 +194,7 @@ def ft_in_title(
         if not featured:
             return False
 
-        self._log.info("{}", displayable_path(item.path))
+        self._log.info("{.filepath}", item)
 
         # Attempt to find the featured artist.
         feat_part = find_feat_part(artist, albumartist)
diff --git a/beetsplug/hook.py b/beetsplug/hook.py
index 90d66553a6..b8869eca40 100644
--- a/beetsplug/hook.py
+++ b/beetsplug/hook.py
@@ -62,7 +62,7 @@ def __init__(self):
     def create_and_register_hook(self, event, command):
         def hook_function(**kwargs):
             if command is None or len(command) == 0:
-                self._log.error('invalid command "{0}"', command)
+                self._log.error('invalid command "{}"', command)
                 return
 
             # For backwards compatibility, use a string formatter that decodes
@@ -74,7 +74,7 @@ def hook_function(**kwargs):
             ]
 
             self._log.debug(
-                'running command "{0}" for event {1}',
+                'running command "{}" for event {}',
                 " ".join(command_pieces),
                 event,
             )
@@ -83,9 +83,9 @@ def hook_function(**kwargs):
                 subprocess.check_call(command_pieces)
             except subprocess.CalledProcessError as exc:
                 self._log.error(
-                    "hook for {0} exited with status {1}", event, exc.returncode
+                    "hook for {} exited with status {.returncode}", event, exc
                 )
             except OSError as exc:
-                self._log.error("hook for {0} failed: {1}", event, exc)
+                self._log.error("hook for {} failed: {}", event, exc)
 
         self.register_listener(event, hook_function)
diff --git a/beetsplug/ihate.py b/beetsplug/ihate.py
index d6357294db..54a61384ce 100644
--- a/beetsplug/ihate.py
+++ b/beetsplug/ihate.py
@@ -70,10 +70,10 @@ def import_task_choice_event(self, session, task):
                 self._log.debug("processing your hate")
                 if self.do_i_hate_this(task, skip_queries):
                     task.choice_flag = Action.SKIP
-                    self._log.info("skipped: {0}", summary(task))
+                    self._log.info("skipped: {}", summary(task))
                     return
                 if self.do_i_hate_this(task, warn_queries):
-                    self._log.info("you may hate this: {0}", summary(task))
+                    self._log.info("you may hate this: {}", summary(task))
             else:
                 self._log.debug("nothing to do")
         else:
diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py
index 2564f26b28..f728a104f2 100644
--- a/beetsplug/importadded.py
+++ b/beetsplug/importadded.py
@@ -94,7 +94,7 @@ def record_import_mtime(self, item, source, destination):
         mtime = os.stat(util.syspath(source)).st_mtime
         self.item_mtime[destination] = mtime
         self._log.debug(
-            "Recorded mtime {0} for item '{1}' imported from '{2}'",
+            "Recorded mtime {} for item '{}' imported from '{}'",
             mtime,
             util.displayable_path(destination),
             util.displayable_path(source),
@@ -103,9 +103,9 @@ def record_import_mtime(self, item, source, destination):
     def update_album_times(self, lib, album):
         if self.reimported_album(album):
             self._log.debug(
-                "Album '{0}' is reimported, skipping import of "
+                "Album '{.filepath}' is reimported, skipping import of "
                 "added dates for the album and its items.",
-                util.displayable_path(album.path),
+                album,
             )
             return
 
@@ -119,18 +119,17 @@ def update_album_times(self, lib, album):
                     item.store()
         album.added = min(album_mtimes)
         self._log.debug(
-            "Import of album '{0}', selected album.added={1} "
+            "Import of album '{0.album}', selected album.added={0.added} "
             "from item file mtimes.",
-            album.album,
-            album.added,
+            album,
         )
         album.store()
 
     def update_item_times(self, lib, item):
         if self.reimported_item(item):
             self._log.debug(
-                "Item '{0}' is reimported, skipping import of added date.",
-                util.displayable_path(item.path),
+                "Item '{.filepath}' is reimported, skipping import of added date.",
+                item,
             )
             return
         mtime = self.item_mtime.pop(item.path, None)
@@ -139,9 +138,8 @@ def update_item_times(self, lib, item):
             if self.config["preserve_mtimes"].get(bool):
                 self.write_item_mtime(item, mtime)
             self._log.debug(
-                "Import of item '{0}', selected item.added={1}",
-                util.displayable_path(item.path),
-                item.added,
+                "Import of item '{0.filepath}', selected item.added={0.added}",
+                item,
             )
             item.store()
 
@@ -153,7 +151,6 @@ def update_after_write_time(self, item, path):
             if self.config["preserve_write_mtimes"].get(bool):
                 self.write_item_mtime(item, item.added)
             self._log.debug(
-                "Write of item '{0}', selected item.added={1}",
-                util.displayable_path(item.path),
-                item.added,
+                "Write of item '{0.filepath}', selected item.added={0.added}",
+                item,
             )
diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py
index 0a5a6afe46..a74746f8b1 100644
--- a/beetsplug/importfeeds.py
+++ b/beetsplug/importfeeds.py
@@ -50,7 +50,7 @@ def _build_m3u_filename(basename):
     path = normpath(
         os.path.join(
             config["importfeeds"]["dir"].as_filename(),
-            date + "_" + basename + ".m3u",
+            f"{date}_{basename}.m3u",
         )
     )
     return path
@@ -136,7 +136,7 @@ def _record_items(self, lib, basename, items):
         if "echo" in formats:
             self._log.info("Location of imported music:")
             for path in paths:
-                self._log.info("  {0}", path)
+                self._log.info("  {}", path)
 
     def album_imported(self, lib, album):
         self._record_items(lib, album.album, album.items())
diff --git a/beetsplug/info.py b/beetsplug/info.py
index c4d5aacbfd..cc78aaffef 100644
--- a/beetsplug/info.py
+++ b/beetsplug/info.py
@@ -117,7 +117,6 @@ def print_data(data, item=None, fmt=None):
         return
 
     maxwidth = max(len(key) for key in formatted)
-    lineformat = f"{{0:>{maxwidth}}}: {{1}}"
 
     if path:
         ui.print_(displayable_path(path))
@@ -126,7 +125,7 @@ def print_data(data, item=None, fmt=None):
         value = formatted[field]
         if isinstance(value, list):
             value = "; ".join(value)
-        ui.print_(lineformat.format(field, value))
+        ui.print_(f"{field:>{maxwidth}}: {value}")
 
 
 def print_data_keys(data, item=None):
@@ -139,12 +138,11 @@ def print_data_keys(data, item=None):
     if len(formatted) == 0:
         return
 
-    line_format = "{0}{{0}}".format(" " * 4)
     if path:
         ui.print_(displayable_path(path))
 
     for field in sorted(formatted):
-        ui.print_(line_format.format(field))
+        ui.print_(f"    {field}")
 
 
 class InfoPlugin(BeetsPlugin):
@@ -221,7 +219,7 @@ def run(self, lib, opts, args):
             try:
                 data, item = data_emitter(included_keys or "*")
             except (mediafile.UnreadableFileError, OSError) as ex:
-                self._log.error("cannot read file: {0}", ex)
+                self._log.error("cannot read file: {}", ex)
                 continue
 
             if opts.summarize:
diff --git a/beetsplug/inline.py b/beetsplug/inline.py
index c4258fc838..e9a94ac380 100644
--- a/beetsplug/inline.py
+++ b/beetsplug/inline.py
@@ -28,8 +28,7 @@ class InlineError(Exception):
 
     def __init__(self, code, exc):
         super().__init__(
-            ("error in inline path field code:\n%s\n%s: %s")
-            % (code, type(exc).__name__, str(exc))
+            f"error in inline path field code:\n{code}\n{type(exc).__name__}: {exc}"
         )
 
 
@@ -37,7 +36,8 @@ def _compile_func(body):
     """Given Python code for a function body, return a compiled
     callable that invokes that code.
     """
-    body = "def {}():\n    {}".format(FUNC_NAME, body.replace("\n", "\n    "))
+    body = body.replace("\n", "\n    ")
+    body = f"def {FUNC_NAME}():\n    {body}"
     code = compile(body, "inline", "exec")
     env = {}
     eval(code, env)
@@ -60,14 +60,14 @@ def __init__(self):
         for key, view in itertools.chain(
             config["item_fields"].items(), config["pathfields"].items()
         ):
-            self._log.debug("adding item field {0}", key)
+            self._log.debug("adding item field {}", key)
             func = self.compile_inline(view.as_str(), False)
             if func is not None:
                 self.template_fields[key] = func
 
         # Album fields.
         for key, view in config["album_fields"].items():
-            self._log.debug("adding album field {0}", key)
+            self._log.debug("adding album field {}", key)
             func = self.compile_inline(view.as_str(), True)
             if func is not None:
                 self.album_template_fields[key] = func
@@ -87,7 +87,7 @@ def compile_inline(self, python_code, album):
                 func = _compile_func(python_code)
             except SyntaxError:
                 self._log.error(
-                    "syntax error in inline field definition:\n{0}",
+                    "syntax error in inline field definition:\n{}",
                     traceback.format_exc(),
                 )
                 return
diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py
index 3c6425c06b..8b6d57fd31 100644
--- a/beetsplug/ipfs.py
+++ b/beetsplug/ipfs.py
@@ -77,7 +77,7 @@ def func(lib, opts, args):
                 for album in lib.albums(args):
                     if len(album.items()) == 0:
                         self._log.info(
-                            "{0} does not contain items, aborting", album
+                            "{} does not contain items, aborting", album
                         )
 
                     self.ipfs_add(album)
@@ -122,13 +122,13 @@ def ipfs_add(self, album):
             return False
         try:
             if album.ipfs:
-                self._log.debug("{0} already added", album_dir)
+                self._log.debug("{} already added", album_dir)
                 # Already added to ipfs
                 return False
         except AttributeError:
             pass
 
-        self._log.info("Adding {0} to ipfs", album_dir)
+        self._log.info("Adding {} to ipfs", album_dir)
 
         if self.config["nocopy"]:
             cmd = "ipfs add --nocopy -q -r".split()
@@ -138,7 +138,7 @@ def ipfs_add(self, album):
         try:
             output = util.command_output(cmd).stdout.split()
         except (OSError, subprocess.CalledProcessError) as exc:
-            self._log.error("Failed to add {0}, error: {1}", album_dir, exc)
+            self._log.error("Failed to add {}, error: {}", album_dir, exc)
             return False
         length = len(output)
 
@@ -146,12 +146,12 @@ def ipfs_add(self, album):
             line = line.strip()
             if linenr == length - 1:
                 # last printed line is the album hash
-                self._log.info("album: {0}", line)
+                self._log.info("album: {}", line)
                 album.ipfs = line
             else:
                 try:
                     item = album.items()[linenr]
-                    self._log.info("item: {0}", line)
+                    self._log.info("item: {}", line)
                     item.ipfs = line
                     item.store()
                 except IndexError:
@@ -180,11 +180,11 @@ def ipfs_get_from_hash(self, lib, _hash):
             util.command_output(cmd)
         except (OSError, subprocess.CalledProcessError) as err:
             self._log.error(
-                "Failed to get {0} from ipfs.\n{1}", _hash, err.output
+                "Failed to get {} from ipfs.\n{.output}", _hash, err
             )
             return False
 
-        self._log.info("Getting {0} from ipfs", _hash)
+        self._log.info("Getting {} from ipfs", _hash)
         imp = ui.commands.TerminalImportSession(
             lib, loghandler=None, query=None, paths=[_hash]
         )
@@ -208,7 +208,7 @@ def ipfs_publish(self, lib):
                 msg = f"Failed to publish library. Error: {err}"
                 self._log.error(msg)
                 return False
-            self._log.info("hash of library: {0}", output)
+            self._log.info("hash of library: {}", output)
 
     def ipfs_import(self, lib, args):
         _hash = args[0]
@@ -232,7 +232,7 @@ def ipfs_import(self, lib, args):
             try:
                 util.command_output(cmd)
             except (OSError, subprocess.CalledProcessError):
-                self._log.error(f"Could not import {_hash}")
+                self._log.error("Could not import {}", _hash)
                 return False
 
         # add all albums from remotes into a combined library
@@ -306,7 +306,7 @@ def create_new_album(self, album, tmplib):
             items.append(item)
         if len(items) < 1:
             return False
-        self._log.info("Adding '{0}' to temporary library", album)
+        self._log.info("Adding '{}' to temporary library", album)
         new_album = tmplib.add_album(items)
         new_album.ipfs = album.ipfs
         new_album.store(inherit=False)
diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py
index 00b688d4fe..e2aff24e59 100644
--- a/beetsplug/keyfinder.py
+++ b/beetsplug/keyfinder.py
@@ -65,7 +65,7 @@ def find_key(self, items, write=False):
                     command + [util.syspath(item.path)]
                 ).stdout
             except (subprocess.CalledProcessError, OSError) as exc:
-                self._log.error("execution failed: {0}", exc)
+                self._log.error("execution failed: {}", exc)
                 continue
 
             try:
@@ -73,7 +73,7 @@ def find_key(self, items, write=False):
             except IndexError:
                 # Sometimes keyfinder-cli returns 0 but with no key, usually
                 # when the file is silent or corrupt, so we log and skip.
-                self._log.error("no key returned for path: {0}", item.path)
+                self._log.error("no key returned for path: {.path}", item)
                 continue
 
             try:
@@ -84,9 +84,7 @@ def find_key(self, items, write=False):
 
             item["initial_key"] = key
             self._log.info(
-                "added computed initial key {0} for {1}",
-                key,
-                util.displayable_path(item.path),
+                "added computed initial key {} for {.filepath}", key, item
             )
 
             if write:
diff --git a/beetsplug/kodiupdate.py b/beetsplug/kodiupdate.py
index 2f679c38be..890ab16c4d 100644
--- a/beetsplug/kodiupdate.py
+++ b/beetsplug/kodiupdate.py
@@ -96,10 +96,10 @@ def update(self, lib):
                     continue
 
                 self._log.info(
-                    "Kodi update triggered for {0}:{1}",
+                    "Kodi update triggered for {}:{}",
                     instance["host"],
                     instance["port"],
                 )
             except requests.exceptions.RequestException as e:
-                self._log.warning("Kodi update failed: {0}", str(e))
+                self._log.warning("Kodi update failed: {}", str(e))
                 continue
diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py
index b0808e4b97..dacd72f934 100644
--- a/beetsplug/lastgenre/__init__.py
+++ b/beetsplug/lastgenre/__init__.py
@@ -139,7 +139,7 @@ def setup(self):
 
         # Read the tree
         if c14n_filename:
-            self._log.debug("Loading canonicalization tree {0}", c14n_filename)
+            self._log.debug("Loading canonicalization tree {}", c14n_filename)
             c14n_filename = normpath(c14n_filename)
             with codecs.open(c14n_filename, "r", encoding="utf-8") as f:
                 genres_tree = yaml.safe_load(f)
@@ -277,7 +277,7 @@ def _last_lookup(self, entity, method, *args):
 
         genre = self._genre_cache[key]
         if self.config["extended_debug"]:
-            self._log.debug(f"last.fm (unfiltered) {entity} tags: {genre}")
+            self._log.debug("last.fm (unfiltered) {} tags: {}", entity, genre)
         return genre
 
     def fetch_album_genre(self, obj):
@@ -327,8 +327,8 @@ def _combine_resolve_and_log(
         self, old: list[str], new: list[str]
     ) -> list[str]:
         """Combine old and new genres and process via _resolve_genres."""
-        self._log.debug(f"raw last.fm tags: {new}")
-        self._log.debug(f"existing genres taken into account: {old}")
+        self._log.debug("raw last.fm tags: {}", new)
+        self._log.debug("existing genres taken into account: {}", old)
         combined = old + new
         return self._resolve_genres(combined)
 
@@ -361,7 +361,7 @@ def _try_resolve_stage(stage_label: str, keep_genres, new_genres):
             )
             if resolved_genres:
                 suffix = "whitelist" if self.whitelist else "any"
-                label = stage_label + f", {suffix}"
+                label = f"{stage_label}, {suffix}"
                 if keep_genres:
                     label = f"keep + {label}"
                 return self._format_and_stringify(resolved_genres), label
@@ -583,9 +583,7 @@ def imported(self, session, task):
             item = task.item
             item.genre, src = self._get_genre(item)
             self._log.debug(
-                'genre for track "{0.title}" ({1}): {0.genre}',
-                item,
-                src,
+                'genre for track "{0.title}" ({1}): {0.genre}', item, src
             )
             item.store()
 
@@ -607,12 +605,12 @@ def _tags_for(self, obj, min_weight=None):
         try:
             res = obj.get_top_tags()
         except PYLAST_EXCEPTIONS as exc:
-            self._log.debug("last.fm error: {0}", exc)
+            self._log.debug("last.fm error: {}", exc)
             return []
         except Exception as exc:
             # Isolate bugs in pylast.
             self._log.debug("{}", traceback.format_exc())
-            self._log.error("error in pylast library: {0}", exc)
+            self._log.error("error in pylast library: {}", exc)
             return []
 
         # Filter by weight (optionally).
diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py
index 122e5f9cd6..baa522d143 100644
--- a/beetsplug/lastimport.py
+++ b/beetsplug/lastimport.py
@@ -70,7 +70,7 @@ def _get_things(
         tuple with the total number of pages of results. Includes an MBID, if
         found.
         """
-        doc = self._request(self.ws_prefix + "." + method, cacheable, params)
+        doc = self._request(f"{self.ws_prefix}.{method}", cacheable, params)
 
         toptracks_node = doc.getElementsByTagName("toptracks")[0]
         total_pages = int(toptracks_node.getAttribute("totalPages"))
@@ -120,7 +120,7 @@ def import_lastfm(lib, log):
     if not user:
         raise ui.UserError("You must specify a user name for lastimport")
 
-    log.info("Fetching last.fm library for @{0}", user)
+    log.info("Fetching last.fm library for @{}", user)
 
     page_total = 1
     page_current = 0
@@ -130,7 +130,7 @@ def import_lastfm(lib, log):
     # Iterate through a yet to be known page total count
     while page_current < page_total:
         log.info(
-            "Querying page #{0}{1}...",
+            "Querying page #{}{}...",
             page_current + 1,
             f"/{page_total}" if page_total > 1 else "",
         )
@@ -147,27 +147,27 @@ def import_lastfm(lib, log):
                 unknown_total += unknown
                 break
             else:
-                log.error("ERROR: unable to read page #{0}", page_current + 1)
+                log.error("ERROR: unable to read page #{}", page_current + 1)
                 if retry < retry_limit:
                     log.info(
-                        "Retrying page #{0}... ({1}/{2} retry)",
+                        "Retrying page #{}... ({}/{} retry)",
                         page_current + 1,
                         retry + 1,
                         retry_limit,
                     )
                 else:
                     log.error(
-                        "FAIL: unable to fetch page #{0}, ",
-                        "tried {1} times",
+                        "FAIL: unable to fetch page #{}, ",
+                        "tried {} times",
                         page_current,
                         retry + 1,
                     )
         page_current += 1
 
     log.info("... done!")
-    log.info("finished processing {0} song pages", page_total)
-    log.info("{0} unknown play-counts", unknown_total)
-    log.info("{0} play-counts imported", found_total)
+    log.info("finished processing {} song pages", page_total)
+    log.info("{} unknown play-counts", unknown_total)
+    log.info("{} play-counts imported", found_total)
 
 
 def fetch_tracks(user, page, limit):
@@ -201,7 +201,7 @@ def process_tracks(lib, tracks, log):
     total = len(tracks)
     total_found = 0
     total_fails = 0
-    log.info("Received {0} tracks in this page, processing...", total)
+    log.info("Received {} tracks in this page, processing...", total)
 
     for num in range(0, total):
         song = None
@@ -220,7 +220,7 @@ def process_tracks(lib, tracks, log):
                 else None
             )
 
-        log.debug("query: {0} - {1} ({2})", artist, title, album)
+        log.debug("query: {} - {} ({})", artist, title, album)
 
         # First try to query by musicbrainz's trackid
         if trackid:
@@ -231,7 +231,7 @@ def process_tracks(lib, tracks, log):
         # If not, try just album/title
         if song is None:
             log.debug(
-                "no album match, trying by album/title: {0} - {1}", album, title
+                "no album match, trying by album/title: {} - {}", album, title
             )
             query = dbcore.AndQuery(
                 [
@@ -268,10 +268,9 @@ def process_tracks(lib, tracks, log):
             count = int(song.get("play_count", 0))
             new_count = int(tracks[num].get("playcount", 1))
             log.debug(
-                "match: {0} - {1} ({2}) updating: play_count {3} => {4}",
-                song.artist,
-                song.title,
-                song.album,
+                "match: {0.artist} - {0.title} ({0.album}) updating:"
+                " play_count {1} => {2}",
+                song,
                 count,
                 new_count,
             )
@@ -280,11 +279,11 @@ def process_tracks(lib, tracks, log):
             total_found += 1
         else:
             total_fails += 1
-            log.info("  - No match: {0} - {1} ({2})", artist, title, album)
+            log.info("  - No match: {} - {} ({})", artist, title, album)
 
     if total_fails > 0:
         log.info(
-            "Acquired {0}/{1} play-counts ({2} unknown)",
+            "Acquired {}/{} play-counts ({} unknown)",
             total_found,
             total,
             total_fails,
diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py
index c579645db9..f7b1389ef7 100644
--- a/beetsplug/listenbrainz.py
+++ b/beetsplug/listenbrainz.py
@@ -42,14 +42,14 @@ def _lbupdate(self, lib, log):
         unknown_total = 0
         ls = self.get_listens()
         tracks = self.get_tracks_from_listens(ls)
-        log.info(f"Found {len(ls)} listens")
+        log.info("Found {} listens", len(ls))
         if tracks:
             found, unknown = process_tracks(lib, tracks, log)
             found_total += found
             unknown_total += unknown
         log.info("... done!")
-        log.info("{0} unknown play-counts", unknown_total)
-        log.info("{0} play-counts imported", found_total)
+        log.info("{} unknown play-counts", unknown_total)
+        log.info("{} play-counts imported", found_total)
 
     def _make_request(self, url, params=None):
         """Makes a request to the ListenBrainz API."""
@@ -63,7 +63,7 @@ def _make_request(self, url, params=None):
             response.raise_for_status()
             return response.json()
         except requests.exceptions.RequestException as e:
-            self._log.debug(f"Invalid Search Error: {e}")
+            self._log.debug("Invalid Search Error: {}", e)
             return None
 
     def get_listens(self, min_ts=None, max_ts=None, count=None):
@@ -156,7 +156,7 @@ def get_listenbrainz_playlists(self):
             playlist_info = playlist.get("playlist")
             if playlist_info.get("creator") == "listenbrainz":
                 title = playlist_info.get("title")
-                self._log.debug(f"Playlist title: {title}")
+                self._log.debug("Playlist title: {}", title)
                 playlist_type = (
                     "Exploration" if "Exploration" in title else "Jams"
                 )
@@ -179,9 +179,7 @@ def get_listenbrainz_playlists(self):
             listenbrainz_playlists, key=lambda x: x["date"], reverse=True
         )
         for playlist in listenbrainz_playlists:
-            self._log.debug(
-                f"Playlist: {playlist['type']} - {playlist['date']}"
-            )
+            self._log.debug("Playlist: {0[type]} - {0[date]}", playlist)
         return listenbrainz_playlists
 
     def get_playlist(self, identifier):
diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py
index f1c40ab24d..f492ab3ccd 100644
--- a/beetsplug/lyrics.py
+++ b/beetsplug/lyrics.py
@@ -154,7 +154,7 @@ def generate_alternatives(string, patterns):
         # examples include (live), (remix), and (acoustic).
         r"(.+?)\s+[(].*[)]$",
         # Remove any featuring artists from the title
-        r"(.*?) {}".format(plugins.feat_tokens(for_artist=False)),
+        rf"(.*?) {plugins.feat_tokens(for_artist=False)}",
         # Remove part of title after colon ':' for songs with subtitles
         r"(.+?)\s*:.*",
     ]
@@ -508,9 +508,9 @@ def check_match(
             # log out the candidate that did not make it but was close.
             # This may show a matching candidate with some noise in the name
             self.debug(
-                "({}, {}) does not match ({}, {}) but dist was close: {:.2f}",
-                result.artist,
-                result.title,
+                "({0.artist}, {0.title}) does not match ({1}, {2}) but dist"
+                " was close: {3:.2f}",
+                result,
                 target_artist,
                 target_title,
                 max_dist,
@@ -582,7 +582,7 @@ class Tekstowo(SearchBackend):
     """Fetch lyrics from Tekstowo.pl."""
 
     BASE_URL = "https://www.tekstowo.pl"
-    SEARCH_URL = BASE_URL + "/szukaj,{}.html"
+    SEARCH_URL = f"{BASE_URL}/szukaj,{{}}.html"
 
     def build_url(self, artist, title):
         artistitle = f"{artist.title()} {title.title()}"
@@ -644,7 +644,7 @@ class Google(SearchBackend):
         re.IGNORECASE | re.VERBOSE,
     )
     #: Split cleaned up URL title into artist and title parts.
-    URL_TITLE_PARTS_RE = re.compile(r" +(?:[ :|-]+|par|by) +")
+    URL_TITLE_PARTS_RE = re.compile(r" +(?:[ :|-]+|par|by) +|, ")
 
     SOURCE_DIST_FACTOR = {"www.azlyrics.com": 0.5, "www.songlyrics.com": 0.6}
 
@@ -702,8 +702,8 @@ def make_search_result(
                 result_artist, result_title = "", parts[0]
         else:
             # sort parts by their similarity to the artist
-            parts.sort(key=lambda p: cls.get_part_dist(artist, title, p))
-            result_artist, result_title = parts[0], " ".join(parts[1:])
+            result_artist = min(parts, key=lambda p: string_dist(artist, p))
+            result_title = min(parts, key=lambda p: string_dist(title, p))
 
         return SearchResult(result_artist, result_title, item["link"])
 
@@ -838,15 +838,16 @@ def translate(self, new_lyrics: str, old_lyrics: str) -> str:
         lyrics_language = langdetect.detect(new_lyrics).upper()
         if lyrics_language == self.to_language:
             self.info(
-                "🔵 Lyrics are already in the target language {}",
-                self.to_language,
+                "🔵 Lyrics are already in the target language {.to_language}",
+                self,
             )
             return new_lyrics
 
         if self.from_languages and lyrics_language not in self.from_languages:
             self.info(
-                "🔵 Configuration {} does not permit translating from {}",
-                self.from_languages,
+                "🔵 Configuration {.from_languages} does not permit translating"
+                " from {}",
+                self,
                 lyrics_language,
             )
             return new_lyrics
@@ -854,7 +855,7 @@ def translate(self, new_lyrics: str, old_lyrics: str) -> str:
         lyrics, *url = new_lyrics.split("\n\nSource: ")
         with self.handle_request():
             translated_lines = self.append_translations(lyrics.splitlines())
-            self.info("🟢 Translated lyrics to {}", self.to_language)
+            self.info("🟢 Translated lyrics to {.to_language}", self)
             return "\n\nSource: ".join(["\n".join(translated_lines), *url])
 
 
@@ -1090,7 +1091,7 @@ def add_item_lyrics(self, item: Item, write: bool) -> None:
             return
 
         if lyrics := self.find_lyrics(item):
-            self.info("🟢 Found lyrics: {0}", item)
+            self.info("🟢 Found lyrics: {}", item)
             if translator := self.translator:
                 lyrics = translator.translate(lyrics, item.lyrics)
         else:
diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py
index 7a1289d1b5..2f9ef709e2 100644
--- a/beetsplug/mbcollection.py
+++ b/beetsplug/mbcollection.py
@@ -83,9 +83,7 @@ def _get_collection(self):
         collection = self.config["collection"].as_str()
         if collection:
             if collection not in collection_ids:
-                raise ui.UserError(
-                    "invalid collection ID: {}".format(collection)
-                )
+                raise ui.UserError(f"invalid collection ID: {collection}")
             return collection
 
         # No specified collection. Just return the first collection ID
@@ -156,10 +154,10 @@ def update_album_list(self, lib, album_list, remove_missing=False):
                 if re.match(UUID_REGEX, aid):
                     album_ids.append(aid)
                 else:
-                    self._log.info("skipping invalid MBID: {0}", aid)
+                    self._log.info("skipping invalid MBID: {}", aid)
 
         # Submit to MusicBrainz.
-        self._log.info("Updating MusicBrainz collection {0}...", collection_id)
+        self._log.info("Updating MusicBrainz collection {}...", collection_id)
         submit_albums(collection_id, album_ids)
         if remove_missing:
             self.remove_missing(collection_id, lib.albums())
diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py
index e23c0d6102..93e88dc9e9 100644
--- a/beetsplug/mbsubmit.py
+++ b/beetsplug/mbsubmit.py
@@ -73,7 +73,7 @@ def picard(self, session, task):
             subprocess.Popen([picard_path] + paths)
             self._log.info("launched picard from\n{}", picard_path)
         except OSError as exc:
-            self._log.error(f"Could not open picard, got error:\n{exc}")
+            self._log.error("Could not open picard, got error:\n{}", exc)
 
     def print_tracks(self, session, task):
         for i in sorted(task.items, key=lambda i: i.track):
diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py
index f99e820b59..d4e31851ef 100644
--- a/beetsplug/metasync/__init__.py
+++ b/beetsplug/metasync/__init__.py
@@ -49,7 +49,7 @@ def load_meta_sources():
     meta_sources = {}
 
     for module_path, class_name in SOURCES.items():
-        module = import_module(METASYNC_MODULE + "." + module_path)
+        module = import_module(f"{METASYNC_MODULE}.{module_path}")
         meta_sources[class_name.lower()] = getattr(module, class_name)
 
     return meta_sources
@@ -117,13 +117,13 @@ def func(self, lib, opts, args):
             try:
                 cls = META_SOURCES[player]
             except KeyError:
-                self._log.error("Unknown metadata source '{}'".format(player))
+                self._log.error("Unknown metadata source '{}'", player)
 
             try:
                 meta_source_instances[player] = cls(self.config, self._log)
             except (ImportError, ConfigValueError) as e:
                 self._log.error(
-                    f"Failed to instantiate metadata source {player!r}: {e}"
+                    "Failed to instantiate metadata source {!r}: {}", player, e
                 )
 
         # Avoid needlessly iterating over items
diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py
index 9afe6dbca8..47e6a1a65c 100644
--- a/beetsplug/metasync/amarok.py
+++ b/beetsplug/metasync/amarok.py
@@ -44,11 +44,12 @@ class Amarok(MetaSource):
         "amarok_lastplayed": types.DATE,
     }
 
-    query_xml = '<query version="1.0"> \
-                    <filters> \
-                        <and><include field="filename" value=%s /></and> \
-                    </filters> \
-                </query>'
+    query_xml = """
+        <query version="1.0">
+            <filters>
+                <and><include field="filename" value={} /></and>
+            </filters>
+        </query>"""
 
     def __init__(self, config, log):
         super().__init__(config, log)
@@ -68,7 +69,7 @@ def sync_from_source(self, item):
         # of the result set. So query for the filename and then try to match
         # the correct item from the results we get back
         results = self.collection.Query(
-            self.query_xml % quoteattr(basename(path))
+            self.query_xml.format(quoteattr(basename(path)))
         )
         for result in results:
             if result["xesam:url"] != path:
diff --git a/beetsplug/metasync/itunes.py b/beetsplug/metasync/itunes.py
index f777d0d55a..6f441ef8b5 100644
--- a/beetsplug/metasync/itunes.py
+++ b/beetsplug/metasync/itunes.py
@@ -76,12 +76,12 @@ def __init__(self, config, log):
         library_path = config["itunes"]["library"].as_filename()
 
         try:
-            self._log.debug(f"loading iTunes library from {library_path}")
+            self._log.debug("loading iTunes library from {}", library_path)
             with create_temporary_copy(library_path) as library_copy:
                 with open(library_copy, "rb") as library_copy_f:
                     raw_library = plistlib.load(library_copy_f)
         except OSError as e:
-            raise ConfigValueError("invalid iTunes library: " + e.strerror)
+            raise ConfigValueError(f"invalid iTunes library: {e.strerror}")
         except Exception:
             # It's likely the user configured their '.itl' library (<> xml)
             if os.path.splitext(library_path)[1].lower() != ".xml":
@@ -91,7 +91,7 @@ def __init__(self, config, log):
                 )
             else:
                 hint = ""
-            raise ConfigValueError("invalid iTunes library" + hint)
+            raise ConfigValueError(f"invalid iTunes library{hint}")
 
         # Make the iTunes library queryable using the path
         self.collection = {
@@ -104,7 +104,7 @@ def sync_from_source(self, item):
         result = self.collection.get(util.bytestring_path(item.path).lower())
 
         if not result:
-            self._log.warning(f"no iTunes match found for {item}")
+            self._log.warning("no iTunes match found for {}", item)
             return
 
         item.itunes_rating = result.get("Rating")
diff --git a/beetsplug/missing.py b/beetsplug/missing.py
index d0e956930f..cbdda4599b 100644
--- a/beetsplug/missing.py
+++ b/beetsplug/missing.py
@@ -226,8 +226,8 @@ def _missing(self, album: Album) -> Iterator[Item]:
             for track_info in album_info.tracks:
                 if track_info.track_id not in item_mbids:
                     self._log.debug(
-                        "track {0} in album {1}",
-                        track_info.track_id,
-                        album_info.album_id,
+                        "track {.track_id} in album {.album_id}",
+                        track_info,
+                        album_info,
                     )
                     yield _item(track_info, album_info, album.id)
diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py
index 52ae88e1f2..0a3e1de02a 100644
--- a/beetsplug/mpdstats.py
+++ b/beetsplug/mpdstats.py
@@ -51,8 +51,8 @@ def __init__(self, log):
         if not self.strip_path.endswith("/"):
             self.strip_path += "/"
 
-        self._log.debug("music_directory: {0}", self.music_directory)
-        self._log.debug("strip_path: {0}", self.strip_path)
+        self._log.debug("music_directory: {.music_directory}", self)
+        self._log.debug("strip_path: {.strip_path}", self)
 
         self.client = mpd.MPDClient()
 
@@ -64,7 +64,7 @@ def connect(self):
         if host[0] in ["/", "~"]:
             host = os.path.expanduser(host)
 
-        self._log.info("connecting to {0}:{1}", host, port)
+        self._log.info("connecting to {}:{}", host, port)
         try:
             self.client.connect(host, port)
         except OSError as e:
@@ -89,7 +89,7 @@ def get(self, command, retries=RETRIES):
         try:
             return getattr(self.client, command)()
         except (OSError, mpd.ConnectionError) as err:
-            self._log.error("{0}", err)
+            self._log.error("{}", err)
 
         if retries <= 0:
             # if we exited without breaking, we couldn't reconnect in time :(
@@ -123,7 +123,7 @@ def currentsong(self):
                 result = os.path.join(self.music_directory, file)
             else:
                 result = entry["file"]
-        self._log.debug("returning: {0}", result)
+        self._log.debug("returning: {}", result)
         return result, entry.get("id")
 
     def status(self):
@@ -169,7 +169,7 @@ def get_item(self, path):
         if item:
             return item
         else:
-            self._log.info("item not found: {0}", displayable_path(path))
+            self._log.info("item not found: {}", displayable_path(path))
 
     def update_item(self, item, attribute, value=None, increment=None):
         """Update the beets item. Set attribute to value or increment the value
@@ -188,10 +188,10 @@ def update_item(self, item, attribute, value=None, increment=None):
             item.store()
 
             self._log.debug(
-                "updated: {0} = {1} [{2}]",
+                "updated: {} = {} [{.filepath}]",
                 attribute,
                 item[attribute],
-                displayable_path(item.path),
+                item,
             )
 
     def update_rating(self, item, skipped):
@@ -234,12 +234,12 @@ def handle_song_change(self, song):
     def handle_played(self, song):
         """Updates the play count of a song."""
         self.update_item(song["beets_item"], "play_count", increment=1)
-        self._log.info("played {0}", displayable_path(song["path"]))
+        self._log.info("played {}", displayable_path(song["path"]))
 
     def handle_skipped(self, song):
         """Updates the skip count of a song."""
         self.update_item(song["beets_item"], "skip_count", increment=1)
-        self._log.info("skipped {0}", displayable_path(song["path"]))
+        self._log.info("skipped {}", displayable_path(song["path"]))
 
     def on_stop(self, status):
         self._log.info("stop")
@@ -278,11 +278,11 @@ def on_play(self, status):
                     self.handle_song_change(self.now_playing)
 
         if is_url(path):
-            self._log.info("playing stream {0}", displayable_path(path))
+            self._log.info("playing stream {}", displayable_path(path))
             self.now_playing = None
             return
 
-        self._log.info("playing {0}", displayable_path(path))
+        self._log.info("playing {}", displayable_path(path))
 
         self.now_playing = {
             "started": time.time(),
@@ -307,12 +307,12 @@ def run(self):
             if "player" in events:
                 status = self.mpd.status()
 
-                handler = getattr(self, "on_" + status["state"], None)
+                handler = getattr(self, f"on_{status['state']}", None)
 
                 if handler:
                     handler(status)
                 else:
-                    self._log.debug('unhandled status "{0}"', status)
+                    self._log.debug('unhandled status "{}"', status)
 
             events = self.mpd.events()
 
diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py
index cb53afaa57..5d8fc598ba 100644
--- a/beetsplug/mpdupdate.py
+++ b/beetsplug/mpdupdate.py
@@ -101,8 +101,8 @@ def update_mpd(self, host="localhost", port=6600, password=None):
 
         try:
             s = BufferedSocket(host, port)
-        except OSError as e:
-            self._log.warning("MPD connection failed: {0}", str(e.strerror))
+        except OSError:
+            self._log.warning("MPD connection failed", exc_info=True)
             return
 
         resp = s.readline()
@@ -111,7 +111,7 @@ def update_mpd(self, host="localhost", port=6600, password=None):
             return
 
         if password:
-            s.send(b'password "%s"\n' % password.encode("utf8"))
+            s.send(f'password "{password}"\n'.encode())
             resp = s.readline()
             if b"OK" not in resp:
                 self._log.warning("Authentication failed: {0!r}", resp)
diff --git a/beetsplug/musicbrainz.py b/beetsplug/musicbrainz.py
index b52e44b239..524fb3c8cb 100644
--- a/beetsplug/musicbrainz.py
+++ b/beetsplug/musicbrainz.py
@@ -68,9 +68,7 @@ def __init__(self, reason, verb, query, tb=None):
         super().__init__(reason, verb, tb)
 
     def get_message(self):
-        return "{} in {} with query {}".format(
-            self._reasonstr(), self.verb, repr(self.query)
-        )
+        return f"{self._reasonstr()} in {self.verb} with query {self.query!r}"
 
 
 RELEASE_INCLUDES = list(
@@ -203,7 +201,7 @@ def _multi_artist_credit(
 
 
 def track_url(trackid: str) -> str:
-    return urljoin(BASE_URL, "recording/" + trackid)
+    return urljoin(BASE_URL, f"recording/{trackid}")
 
 
 def _flatten_artist_credit(credit: list[JSONDict]) -> tuple[str, str, str]:
@@ -248,7 +246,7 @@ def _get_related_artist_names(relations, relation_type):
 
 
 def album_url(albumid: str) -> str:
-    return urljoin(BASE_URL, "release/" + albumid)
+    return urljoin(BASE_URL, f"release/{albumid}")
 
 
 def _preferred_release_event(
@@ -293,7 +291,7 @@ def _set_date_str(
                     continue
 
                 if original:
-                    key = "original_" + key
+                    key = f"original_{key}"
                 setattr(info, key, date_num)
 
 
@@ -838,7 +836,7 @@ def album_for_id(
         """
         self._log.debug("Requesting MusicBrainz release {}", album_id)
         if not (albumid := self._extract_id(album_id)):
-            self._log.debug("Invalid MBID ({0}).", album_id)
+            self._log.debug("Invalid MBID ({}).", album_id)
             return None
 
         try:
@@ -875,7 +873,7 @@ def track_for_id(
         or None if no track is found. May raise a MusicBrainzAPIError.
         """
         if not (trackid := self._extract_id(track_id)):
-            self._log.debug("Invalid MBID ({0}).", track_id)
+            self._log.debug("Invalid MBID ({}).", track_id)
             return None
 
         try:
diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py
index ab2d39b2b8..eb2fd8f117 100644
--- a/beetsplug/parentwork.py
+++ b/beetsplug/parentwork.py
@@ -179,10 +179,8 @@ def find_work(self, item, force, verbose):
 
         if not item.mb_workid:
             self._log.info(
-                "No work for {}, \
-add one at https://musicbrainz.org/recording/{}",
+                "No work for {0}, add one at https://musicbrainz.org/recording/{0.mb_trackid}",
                 item,
-                item.mb_trackid,
             )
             return
 
diff --git a/beetsplug/play.py b/beetsplug/play.py
index 3e7ba0a9e6..35b4b1f764 100644
--- a/beetsplug/play.py
+++ b/beetsplug/play.py
@@ -43,7 +43,7 @@ def play(
     """
     # Print number of tracks or albums to be played, log command to be run.
     item_type += "s" if len(selection) > 1 else ""
-    ui.print_("Playing {} {}.".format(len(selection), item_type))
+    ui.print_(f"Playing {len(selection)} {item_type}.")
     log.debug("executing command: {} {!r}", command_str, open_args)
 
     try:
@@ -154,7 +154,7 @@ def _command_str(self, args=None):
                 return f"{command_str} {args}"
         else:
             # Don't include the marker in the command.
-            return command_str.replace(" " + ARGS_MARKER, "")
+            return command_str.replace(f" {ARGS_MARKER}", "")
 
     def _playlist_or_paths(self, paths):
         """Return either the raw paths of items or a playlist of the items."""
@@ -179,9 +179,7 @@ def _exceeds_threshold(
             ui.print_(
                 ui.colorize(
                     "text_warning",
-                    "You are about to queue {} {}.".format(
-                        len(selection), item_type
-                    ),
+                    f"You are about to queue {len(selection)} {item_type}.",
                 )
             )
 
diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py
index 7a27b02a32..07c12e0e0c 100644
--- a/beetsplug/playlist.py
+++ b/beetsplug/playlist.py
@@ -123,7 +123,7 @@ def item_removed(self, item):
 
     def cli_exit(self, lib):
         for playlist in self.find_playlists():
-            self._log.info(f"Updating playlist: {playlist}")
+            self._log.info("Updating playlist: {}", playlist)
             base_dir = beets.util.bytestring_path(
                 self.relative_to
                 if self.relative_to
@@ -133,21 +133,16 @@ def cli_exit(self, lib):
             try:
                 self.update_playlist(playlist, base_dir)
             except beets.util.FilesystemError:
-                self._log.error(
-                    "Failed to update playlist: {}".format(
-                        beets.util.displayable_path(playlist)
-                    )
-                )
+                self._log.error("Failed to update playlist: {}", playlist)
 
     def find_playlists(self):
         """Find M3U playlists in the playlist directory."""
+        playlist_dir = beets.util.syspath(self.playlist_dir)
         try:
-            dir_contents = os.listdir(beets.util.syspath(self.playlist_dir))
+            dir_contents = os.listdir(playlist_dir)
         except OSError:
             self._log.warning(
-                "Unable to open playlist directory {}".format(
-                    beets.util.displayable_path(self.playlist_dir)
-                )
+                "Unable to open playlist directory {.playlist_dir}", self
             )
             return
 
@@ -195,9 +190,10 @@ def update_playlist(self, filename, base_dir):
 
         if changes or deletions:
             self._log.info(
-                "Updated playlist {} ({} changes, {} deletions)".format(
-                    filename, changes, deletions
-                )
+                "Updated playlist {} ({} changes, {} deletions)",
+                filename,
+                changes,
+                deletions,
             )
             beets.util.copy(new_playlist, filename, replace=True)
         beets.util.remove(new_playlist)
diff --git a/beetsplug/plexupdate.py b/beetsplug/plexupdate.py
index 9b4419c715..5e255d45b7 100644
--- a/beetsplug/plexupdate.py
+++ b/beetsplug/plexupdate.py
@@ -22,9 +22,7 @@ def get_music_section(
 ):
     """Getting the section key for the music library in Plex."""
     api_endpoint = append_token("library/sections", token)
-    url = urljoin(
-        "{}://{}:{}".format(get_protocol(secure), host, port), api_endpoint
-    )
+    url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint)
 
     # Sends request.
     r = requests.get(
@@ -54,9 +52,7 @@ def update_plex(host, port, token, library_name, secure, ignore_cert_errors):
     )
     api_endpoint = f"library/sections/{section_key}/refresh"
     api_endpoint = append_token(api_endpoint, token)
-    url = urljoin(
-        "{}://{}:{}".format(get_protocol(secure), host, port), api_endpoint
-    )
+    url = urljoin(f"{get_protocol(secure)}://{host}:{port}", api_endpoint)
 
     # Sends request and returns requests object.
     r = requests.get(
@@ -70,7 +66,7 @@ def update_plex(host, port, token, library_name, secure, ignore_cert_errors):
 def append_token(url, token):
     """Appends the Plex Home token to the api call if required."""
     if token:
-        url += "?" + urlencode({"X-Plex-Token": token})
+        url += f"?{urlencode({'X-Plex-Token': token})}"
     return url
 
 
diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py
index 96c8543147..3e777d9773 100644
--- a/beetsplug/replaygain.py
+++ b/beetsplug/replaygain.py
@@ -70,9 +70,7 @@ def call(args: list[str], log: Logger, **kwargs: Any):
         return command_output(args, **kwargs)
     except subprocess.CalledProcessError as e:
         log.debug(e.output.decode("utf8", "ignore"))
-        raise ReplayGainError(
-            "{} exited with status {}".format(args[0], e.returncode)
-        )
+        raise ReplayGainError(f"{args[0]} exited with status {e.returncode}")
 
 
 def db_to_lufs(db: float) -> float:
@@ -143,9 +141,8 @@ def _store_track_gain(self, item: Item, track_gain: Gain):
         item.rg_track_peak = track_gain.peak
         item.store()
         self._log.debug(
-            "applied track gain {0} LU, peak {1} of FS",
-            item.rg_track_gain,
-            item.rg_track_peak,
+            "applied track gain {0.rg_track_gain} LU, peak {0.rg_track_peak} of FS",
+            item,
         )
 
     def _store_album_gain(self, item: Item, album_gain: Gain):
@@ -157,9 +154,8 @@ def _store_album_gain(self, item: Item, album_gain: Gain):
         item.rg_album_peak = album_gain.peak
         item.store()
         self._log.debug(
-            "applied album gain {0} LU, peak {1} of FS",
-            item.rg_album_gain,
-            item.rg_album_peak,
+            "applied album gain {0.rg_album_gain} LU, peak {0.rg_album_peak} of FS",
+            item,
         )
 
     def _store_track(self, write: bool):
@@ -170,15 +166,14 @@ def _store_track(self, write: bool):
             # `track_gains` without throwing FatalReplayGainError
             #  => raise non-fatal exception & continue
             raise ReplayGainError(
-                "ReplayGain backend `{}` failed for track {}".format(
-                    self.backend_name, item
-                )
+                f"ReplayGain backend `{self.backend_name}` failed for track"
+                f" {item}"
             )
 
         self._store_track_gain(item, self.track_gains[0])
         if write:
             item.try_write()
-        self._log.debug("done analyzing {0}", item)
+        self._log.debug("done analyzing {}", item)
 
     def _store_album(self, write: bool):
         """Store track/album gains for all tracks of the task in the database."""
@@ -191,17 +186,15 @@ def _store_album(self, write: bool):
             # `album_gain` without throwing FatalReplayGainError
             #  => raise non-fatal exception & continue
             raise ReplayGainError(
-                "ReplayGain backend `{}` failed "
-                "for some tracks in album {}".format(
-                    self.backend_name, self.album
-                )
+                f"ReplayGain backend `{self.backend_name}` failed "
+                f"for some tracks in album {self.album}"
             )
         for item, track_gain in zip(self.items, self.track_gains):
             self._store_track_gain(item, track_gain)
             self._store_album_gain(item, self.album_gain)
             if write:
                 item.try_write()
-            self._log.debug("done analyzing {0}", item)
+            self._log.debug("done analyzing {}", item)
 
     def store(self, write: bool):
         """Store computed gains for the items of this task in the database."""
@@ -235,7 +228,7 @@ def __init__(
     def _store_track_gain(self, item: Item, track_gain: Gain):
         item.r128_track_gain = track_gain.gain
         item.store()
-        self._log.debug("applied r128 track gain {0} LU", item.r128_track_gain)
+        self._log.debug("applied r128 track gain {.r128_track_gain} LU", item)
 
     def _store_album_gain(self, item: Item, album_gain: Gain):
         """
@@ -244,7 +237,7 @@ def _store_album_gain(self, item: Item, album_gain: Gain):
         """
         item.r128_album_gain = album_gain.gain
         item.store()
-        self._log.debug("applied r128 album gain {0} LU", item.r128_album_gain)
+        self._log.debug("applied r128 album gain {.r128_album_gain} LU", item)
 
 
 AnyRgTask = TypeVar("AnyRgTask", bound=RgTask)
@@ -385,10 +378,7 @@ def sum_of_track_powers(track_gain: Gain, track_n_blocks: int):
         album_gain = target_level_lufs - album_gain
 
         self._log.debug(
-            "{}: gain {} LU, peak {}",
-            task.album,
-            album_gain,
-            album_peak,
+            "{.album}: gain {} LU, peak {}", task, album_gain, album_peak
         )
 
         task.album_gain = Gain(album_gain, album_peak)
@@ -431,9 +421,9 @@ def _analyse_item(
         target_level_lufs = db_to_lufs(target_level)
 
         # call ffmpeg
-        self._log.debug(f"analyzing {item}")
+        self._log.debug("analyzing {}", item)
         cmd = self._construct_cmd(item, peak_method)
-        self._log.debug("executing {0}", " ".join(map(displayable_path, cmd)))
+        self._log.debug("executing {}", " ".join(map(displayable_path, cmd)))
         output = call(cmd, self._log).stderr.splitlines()
 
         # parse output
@@ -501,12 +491,10 @@ def _analyse_item(
                 if self._parse_float(b"M: " + line[1]) >= gating_threshold:
                     n_blocks += 1
             self._log.debug(
-                "{}: {} blocks over {} LUFS".format(
-                    item, n_blocks, gating_threshold
-                )
+                "{}: {} blocks over {} LUFS", item, n_blocks, gating_threshold
             )
 
-        self._log.debug("{}: gain {} LU, peak {}".format(item, gain, peak))
+        self._log.debug("{}: gain {} LU, peak {}", item, gain, peak)
 
         return Gain(gain, peak), n_blocks
 
@@ -526,9 +514,7 @@ def _find_line(
             if output[i].startswith(search):
                 return i
         raise ReplayGainError(
-            "ffmpeg output: missing {} after line {}".format(
-                repr(search), start_line
-            )
+            f"ffmpeg output: missing {search!r} after line {start_line}"
         )
 
     def _parse_float(self, line: bytes) -> float:
@@ -575,7 +561,7 @@ def __init__(self, config: ConfigView, log: Logger):
             # Explicit executable path.
             if not os.path.isfile(self.command):
                 raise FatalReplayGainError(
-                    "replaygain command does not exist: {}".format(self.command)
+                    f"replaygain command does not exist: {self.command}"
                 )
         else:
             # Check whether the program is in $PATH.
@@ -663,8 +649,8 @@ def compute_gain(
         cmd = cmd + ["-d", str(int(target_level - 89))]
         cmd = cmd + [syspath(i.path) for i in items]
 
-        self._log.debug("analyzing {0} files", len(items))
-        self._log.debug("executing {0}", " ".join(map(displayable_path, cmd)))
+        self._log.debug("analyzing {} files", len(items))
+        self._log.debug("executing {}", " ".join(map(displayable_path, cmd)))
         output = call(cmd, self._log).stdout
         self._log.debug("analysis finished")
         return self.parse_tool_output(
@@ -680,7 +666,7 @@ def parse_tool_output(self, text: bytes, num_lines: int) -> list[Gain]:
         for line in text.split(b"\n")[1 : num_lines + 1]:
             parts = line.split(b"\t")
             if len(parts) != 6 or parts[0] == b"File":
-                self._log.debug("bad tool output: {0}", text)
+                self._log.debug("bad tool output: {}", text)
                 raise ReplayGainError("mp3gain failed")
 
             # _file = parts[0]
@@ -1105,9 +1091,8 @@ def _compute_track_gain(self, item: Item, target_level: float):
         )
 
         self._log.debug(
-            "ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}",
-            item.artist,
-            item.title,
+            "ReplayGain for track {0.artist} - {0.title}: {1:.2f}, {2:.2f}",
+            item,
             rg_track_gain,
             rg_track_peak,
         )
@@ -1132,7 +1117,7 @@ def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask:
             )
             track_gains.append(Gain(gain=rg_track_gain, peak=rg_track_peak))
             self._log.debug(
-                "ReplayGain for track {0}: {1:.2f}, {2:.2f}",
+                "ReplayGain for track {}: {.2f}, {.2f}",
                 item,
                 rg_track_gain,
                 rg_track_peak,
@@ -1145,8 +1130,8 @@ def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask:
             rg_album_gain, task.target_level
         )
         self._log.debug(
-            "ReplayGain for album {0}: {1:.2f}, {2:.2f}",
-            task.items[0].album,
+            "ReplayGain for album {.items[0].album}: {.2f}, {.2f}",
+            task,
             rg_album_gain,
             rg_album_peak,
         )
@@ -1229,10 +1214,8 @@ def __init__(self) -> None:
 
         if self.backend_name not in BACKENDS:
             raise ui.UserError(
-                "Selected ReplayGain backend {} is not supported. "
-                "Please select one of: {}".format(
-                    self.backend_name, ", ".join(BACKENDS.keys())
-                )
+                f"Selected ReplayGain backend {self.backend_name} is not"
+                f" supported. Please select one of: {', '.join(BACKENDS)}"
             )
 
         # FIXME: Consider renaming the configuration option to 'peak_method'
@@ -1240,10 +1223,9 @@ def __init__(self) -> None:
         peak_method = self.config["peak"].as_str()
         if peak_method not in PeakMethod.__members__:
             raise ui.UserError(
-                "Selected ReplayGain peak method {} is not supported. "
-                "Please select one of: {}".format(
-                    peak_method, ", ".join(PeakMethod.__members__)
-                )
+                f"Selected ReplayGain peak method {peak_method} is not"
+                " supported. Please select one of:"
+                f" {', '.join(PeakMethod.__members__)}"
             )
         # This only applies to plain old rg tags, r128 doesn't store peak
         # values.
@@ -1348,19 +1330,19 @@ def handle_album(self, album: Album, write: bool, force: bool = False):
         items, nothing is done.
         """
         if not force and not self.album_requires_gain(album):
-            self._log.info("Skipping album {0}", album)
+            self._log.info("Skipping album {}", album)
             return
 
         items_iter = iter(album.items())
         use_r128 = self.should_use_r128(next(items_iter))
         if any(use_r128 != self.should_use_r128(i) for i in items_iter):
             self._log.error(
-                "Cannot calculate gain for album {0} (incompatible formats)",
+                "Cannot calculate gain for album {} (incompatible formats)",
                 album,
             )
             return
 
-        self._log.info("analyzing {0}", album)
+        self._log.info("analyzing {}", album)
 
         discs: dict[int, list[Item]] = {}
         if self.config["per_disc"].get(bool):
@@ -1384,7 +1366,7 @@ def store_cb(task: RgTask):
                     callback=store_cb,
                 )
             except ReplayGainError as e:
-                self._log.info("ReplayGain error: {0}", e)
+                self._log.info("ReplayGain error: {}", e)
             except FatalReplayGainError as e:
                 raise ui.UserError(f"Fatal replay gain error: {e}")
 
@@ -1396,7 +1378,7 @@ def handle_track(self, item: Item, write: bool, force: bool = False):
         in the item, nothing is done.
         """
         if not force and not self.track_requires_gain(item):
-            self._log.info("Skipping track {0}", item)
+            self._log.info("Skipping track {}", item)
             return
 
         use_r128 = self.should_use_r128(item)
@@ -1413,7 +1395,7 @@ def store_cb(task: RgTask):
                 callback=store_cb,
             )
         except ReplayGainError as e:
-            self._log.info("ReplayGain error: {0}", e)
+            self._log.info("ReplayGain error: {}", e)
         except FatalReplayGainError as e:
             raise ui.UserError(f"Fatal replay gain error: {e}")
 
@@ -1526,18 +1508,16 @@ def command_func(
             if opts.album:
                 albums = lib.albums(args)
                 self._log.info(
-                    "Analyzing {} albums ~ {} backend...".format(
-                        len(albums), self.backend_name
-                    )
+                    f"Analyzing {len(albums)} albums ~"
+                    f" {self.backend_name} backend..."
                 )
                 for album in albums:
                     self.handle_album(album, write, force)
             else:
                 items = lib.items(args)
                 self._log.info(
-                    "Analyzing {} tracks ~ {} backend...".format(
-                        len(items), self.backend_name
-                    )
+                    f"Analyzing {len(items)} tracks ~"
+                    f" {self.backend_name} backend..."
                 )
                 for item in items:
                     self.handle_track(item, write, force)
@@ -1556,8 +1536,10 @@ def commands(self) -> list[ui.Subcommand]:
             "--threads",
             dest="threads",
             type=int,
-            help="change the number of threads, \
-            defaults to maximum available processors",
+            help=(
+                "change the number of threads, defaults to maximum available"
+                " processors"
+            ),
         )
         cmd.parser.add_option(
             "-f",
@@ -1565,8 +1547,10 @@ def commands(self) -> list[ui.Subcommand]:
             dest="force",
             action="store_true",
             default=False,
-            help="analyze all files, including those that "
-            "already have ReplayGain metadata",
+            help=(
+                "analyze all files, including those that already have"
+                " ReplayGain metadata"
+            ),
         )
         cmd.parser.add_option(
             "-w",
diff --git a/beetsplug/rewrite.py b/beetsplug/rewrite.py
index 83829d6579..1cc21ad75e 100644
--- a/beetsplug/rewrite.py
+++ b/beetsplug/rewrite.py
@@ -57,9 +57,9 @@ def __init__(self):
                 raise ui.UserError("invalid rewrite specification")
             if fieldname not in library.Item._fields:
                 raise ui.UserError(
-                    "invalid field name (%s) in rewriter" % fieldname
+                    f"invalid field name ({fieldname}) in rewriter"
                 )
-            self._log.debug("adding template field {0}", key)
+            self._log.debug("adding template field {}", key)
             pattern = re.compile(pattern.lower())
             rules[fieldname].append((pattern, value))
             if fieldname == "artist":
diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py
index 813effb5ff..c398941372 100644
--- a/beetsplug/scrub.py
+++ b/beetsplug/scrub.py
@@ -59,9 +59,7 @@ def commands(self):
         def scrub_func(lib, opts, args):
             # Walk through matching files and remove tags.
             for item in lib.items(args):
-                self._log.info(
-                    "scrubbing: {0}", util.displayable_path(item.path)
-                )
+                self._log.info("scrubbing: {.filepath}", item)
                 self._scrub_item(item, opts.write)
 
         scrub_cmd = ui.Subcommand("scrub", help="clean audio tags")
@@ -110,7 +108,7 @@ def _scrub(self, path):
                 f.save()
             except (OSError, mutagen.MutagenError) as exc:
                 self._log.error(
-                    "could not scrub {0}: {1}", util.displayable_path(path), exc
+                    "could not scrub {}: {}", util.displayable_path(path), exc
                 )
 
     def _scrub_item(self, item, restore):
@@ -124,7 +122,7 @@ def _scrub_item(self, item, restore):
                     util.syspath(item.path), config["id3v23"].get(bool)
                 )
             except mediafile.UnreadableFileError as exc:
-                self._log.error("could not open file to scrub: {0}", exc)
+                self._log.error("could not open file to scrub: {}", exc)
                 return
             images = mf.images
 
@@ -144,12 +142,10 @@ def _scrub_item(self, item, restore):
                     mf.images = images
                     mf.save()
                 except mediafile.UnreadableFileError as exc:
-                    self._log.error("could not write tags: {0}", exc)
+                    self._log.error("could not write tags: {}", exc)
 
     def import_task_files(self, session, task):
         """Automatically scrub imported files."""
         for item in task.imported_items():
-            self._log.debug(
-                "auto-scrubbing {0}", util.displayable_path(item.path)
-            )
+            self._log.debug("auto-scrubbing {.filepath}", item)
             self._scrub_item(item, ui.should_write())
diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py
index e65d596499..8203ce4efd 100644
--- a/beetsplug/smartplaylist.py
+++ b/beetsplug/smartplaylist.py
@@ -138,10 +138,9 @@ def update_cmd(self, lib, opts, args):
                 if name in args
             }
             if not playlists:
+                unmatched = [name for name, _, _ in self._unmatched_playlists]
                 raise ui.UserError(
-                    "No playlist matching any of {} found".format(
-                        [name for name, _, _ in self._unmatched_playlists]
-                    )
+                    f"No playlist matching any of {unmatched} found"
                 )
 
             self._matched_playlists = playlists
@@ -235,7 +234,7 @@ def db_change(self, lib, model):
         for playlist in self._unmatched_playlists:
             n, (q, _), (a_q, _) = playlist
             if self.matches(model, q, a_q):
-                self._log.debug("{0} will be updated because of {1}", n, model)
+                self._log.debug("{} will be updated because of {}", n, model)
                 self._matched_playlists.add(playlist)
                 self.register_listener("cli_exit", self.update_playlists)
 
@@ -244,12 +243,12 @@ def db_change(self, lib, model):
     def update_playlists(self, lib, pretend=False):
         if pretend:
             self._log.info(
-                "Showing query results for {0} smart playlists...",
+                "Showing query results for {} smart playlists...",
                 len(self._matched_playlists),
             )
         else:
             self._log.info(
-                "Updating {0} smart playlists...", len(self._matched_playlists)
+                "Updating {} smart playlists...", len(self._matched_playlists)
             )
 
         playlist_dir = self.config["playlist_dir"].as_filename()
@@ -268,7 +267,7 @@ def update_playlists(self, lib, pretend=False):
             if pretend:
                 self._log.info("Results for playlist {}:", name)
             else:
-                self._log.info("Creating playlist {0}", name)
+                self._log.info("Creating playlist {}", name)
             items = []
 
             if query:
@@ -331,8 +330,9 @@ def update_playlists(self, lib, pretend=False):
                                 for key, value in attr
                             ]
                             attrs = "".join(al)
-                            comment = "#EXTINF:{}{},{} - {}\n".format(
-                                int(item.length), attrs, item.artist, item.title
+                            comment = (
+                                f"#EXTINF:{int(item.length)}{attrs},"
+                                f"{item.artist} - {item.title}\n"
                             )
                         f.write(comment.encode("utf-8") + entry.uri + b"\n")
             # Send an event when playlists were updated.
@@ -340,13 +340,11 @@ def update_playlists(self, lib, pretend=False):
 
         if pretend:
             self._log.info(
-                "Displayed results for {0} playlists",
+                "Displayed results for {} playlists",
                 len(self._matched_playlists),
             )
         else:
-            self._log.info(
-                "{0} playlists updated", len(self._matched_playlists)
-            )
+            self._log.info("{} playlists updated", len(self._matched_playlists))
 
 
 class PlaylistItem:
diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py
index f45ed158b7..d839273286 100644
--- a/beetsplug/spotify.py
+++ b/beetsplug/spotify.py
@@ -168,8 +168,9 @@ def _authenticate(self) -> None:
         c_secret: str = self.config["client_secret"].as_str()
 
         headers = {
-            "Authorization": "Basic {}".format(
-                base64.b64encode(f"{c_id}:{c_secret}".encode()).decode()
+            "Authorization": (
+                "Basic"
+                f" {base64.b64encode(f'{c_id}:{c_secret}'.encode()).decode()}"
             )
         }
         response = requests.post(
@@ -182,14 +183,12 @@ def _authenticate(self) -> None:
             response.raise_for_status()
         except requests.exceptions.HTTPError as e:
             raise ui.UserError(
-                "Spotify authorization failed: {}\n{}".format(e, response.text)
+                f"Spotify authorization failed: {e}\n{response.text}"
             )
         self.access_token = response.json()["access_token"]
 
         # Save the token for later use.
-        self._log.debug(
-            "{} access token: {}", self.data_source, self.access_token
-        )
+        self._log.debug("{0.data_source} access token: {0.access_token}", self)
         with open(self._tokenfile(), "w") as f:
             json.dump({"access_token": self.access_token}, f)
 
@@ -227,16 +226,16 @@ def _handle_response(
             self._log.error("ReadTimeout.")
             raise APIError("Request timed out.")
         except requests.exceptions.ConnectionError as e:
-            self._log.error(f"Network error: {e}")
+            self._log.error("Network error: {}", e)
             raise APIError("Network error.")
         except requests.exceptions.RequestException as e:
             if e.response is None:
-                self._log.error(f"Request failed: {e}")
+                self._log.error("Request failed: {}", e)
                 raise APIError("Request failed.")
             if e.response.status_code == 401:
                 self._log.debug(
-                    f"{self.data_source} access token has expired. "
-                    f"Reauthenticating."
+                    "{.data_source} access token has expired. Reauthenticating.",
+                    self,
                 )
                 self._authenticate()
                 return self._handle_response(
@@ -255,7 +254,7 @@ def _handle_response(
                     "Retry-After", DEFAULT_WAITING_TIME
                 )
                 self._log.debug(
-                    f"Too many API requests. Retrying after {seconds} seconds."
+                    "Too many API requests. Retrying after {} seconds.", seconds
                 )
                 time.sleep(int(seconds) + 1)
                 return self._handle_response(
@@ -276,7 +275,7 @@ def _handle_response(
                     f"URL:\n{url}\nparams:\n{params}"
                 )
             else:
-                self._log.error(f"Request failed. Error: {e}")
+                self._log.error("Request failed. Error: {}", e)
                 raise APIError("Request failed.")
 
     def album_for_id(self, album_id: str) -> AlbumInfo | None:
@@ -291,7 +290,9 @@ def album_for_id(self, album_id: str) -> AlbumInfo | None:
         if not (spotify_id := self._extract_id(album_id)):
             return None
 
-        album_data = self._handle_response("get", self.album_url + spotify_id)
+        album_data = self._handle_response(
+            "get", f"{self.album_url}{spotify_id}"
+        )
         if album_data["name"] == "":
             self._log.debug("Album removed from Spotify: {}", album_id)
             return None
@@ -314,9 +315,7 @@ def album_for_id(self, album_id: str) -> AlbumInfo | None:
         else:
             raise ui.UserError(
                 "Invalid `release_date_precision` returned "
-                "by {} API: '{}'".format(
-                    self.data_source, release_date_precision
-                )
+                f"by {self.data_source} API: '{release_date_precision}'"
             )
 
         tracks_data = album_data["tracks"]
@@ -409,7 +408,7 @@ def track_for_id(self, track_id: str) -> None | TrackInfo:
         # release) and `track.medium_total` (total number of tracks on
         # the track's disc).
         album_data = self._handle_response(
-            "get", self.album_url + track_data["album"]["id"]
+            "get", f"{self.album_url}{track_data['album']['id']}"
         )
         medium_total = 0
         for i, track_data in enumerate(album_data["tracks"]["items"], start=1):
@@ -438,7 +437,7 @@ def _search_api(
             filters=filters, query_string=query_string
         )
 
-        self._log.debug(f"Searching {self.data_source} for '{query}'")
+        self._log.debug("Searching {.data_source} for '{}'", self, query)
         try:
             response = self._handle_response(
                 "get",
@@ -448,11 +447,11 @@ def _search_api(
         except APIError as e:
             self._log.debug("Spotify API error: {}", e)
             return ()
-        response_data = response.get(query_type + "s", {}).get("items", [])
+        response_data = response.get(f"{query_type}s", {}).get("items", [])
         self._log.debug(
-            "Found {} result(s) from {} for '{}'",
+            "Found {} result(s) from {.data_source} for '{}'",
             len(response_data),
-            self.data_source,
+            self,
             query,
         )
         return response_data
@@ -472,17 +471,17 @@ def queries(lib, opts, args):
             "-m",
             "--mode",
             action="store",
-            help='"open" to open {} with playlist, '
-            '"list" to print (default)'.format(self.data_source),
+            help=(
+                f'"open" to open {self.data_source} with playlist, '
+                '"list" to print (default)'
+            ),
         )
         spotify_cmd.parser.add_option(
             "-f",
             "--show-failures",
             action="store_true",
             dest="show_failures",
-            help="list tracks that did not match a {} ID".format(
-                self.data_source
-            ),
+            help=f"list tracks that did not match a {self.data_source} ID",
         )
         spotify_cmd.func = queries
 
@@ -515,7 +514,7 @@ def _parse_opts(self, opts):
 
         if self.config["mode"].get() not in ["list", "open"]:
             self._log.warning(
-                "{0} is not a valid mode", self.config["mode"].get()
+                "{} is not a valid mode", self.config["mode"].get()
             )
             return False
 
@@ -538,8 +537,8 @@ def _match_library_tracks(self, library: Library, keywords: str):
 
         if not items:
             self._log.debug(
-                "Your beets query returned no items, skipping {}.",
-                self.data_source,
+                "Your beets query returned no items, skipping {.data_source}.",
+                self,
             )
             return
 
@@ -594,8 +593,8 @@ def _match_library_tracks(self, library: Library, keywords: str):
                 or self.config["tiebreak"].get() == "first"
             ):
                 self._log.debug(
-                    "{} track(s) found, count: {}",
-                    self.data_source,
+                    "{.data_source} track(s) found, count: {}",
+                    self,
                     len(response_data_tracks),
                 )
                 chosen_result = response_data_tracks[0]
@@ -618,19 +617,19 @@ def _match_library_tracks(self, library: Library, keywords: str):
         if failure_count > 0:
             if self.config["show_failures"].get():
                 self._log.info(
-                    "{} track(s) did not match a {} ID:",
+                    "{} track(s) did not match a {.data_source} ID:",
                     failure_count,
-                    self.data_source,
+                    self,
                 )
                 for track in failures:
                     self._log.info("track: {}", track)
                 self._log.info("")
             else:
                 self._log.warning(
-                    "{} track(s) did not match a {} ID:\n"
+                    "{} track(s) did not match a {.data_source} ID:\n"
                     "use --show-failures to display",
                     failure_count,
-                    self.data_source,
+                    self,
                 )
 
         return results
@@ -647,20 +646,18 @@ def _output_match_results(self, results):
             spotify_ids = [track_data["id"] for track_data in results]
             if self.config["mode"].get() == "open":
                 self._log.info(
-                    "Attempting to open {} with playlist".format(
-                        self.data_source
-                    )
+                    "Attempting to open {.data_source} with playlist", self
                 )
-                spotify_url = "spotify:trackset:Playlist:" + ",".join(
-                    spotify_ids
+                spotify_url = (
+                    f"spotify:trackset:Playlist:{','.join(spotify_ids)}"
                 )
                 webbrowser.open(spotify_url)
             else:
                 for spotify_id in spotify_ids:
-                    print(self.open_track_url + spotify_id)
+                    print(f"{self.open_track_url}{spotify_id}")
         else:
             self._log.warning(
-                f"No {self.data_source} tracks found from beets query"
+                "No {.data_source} tracks found from beets query", self
             )
 
     def _fetch_info(self, items, write, force):
@@ -705,7 +702,7 @@ def _fetch_info(self, items, write, force):
 
     def track_info(self, track_id: str):
         """Fetch a track's popularity and external IDs using its Spotify ID."""
-        track_data = self._handle_response("get", self.track_url + track_id)
+        track_data = self._handle_response("get", f"{self.track_url}{track_id}")
         external_ids = track_data.get("external_ids", {})
         popularity = track_data.get("popularity")
         self._log.debug(
@@ -724,7 +721,7 @@ def track_audio_features(self, track_id: str):
         """Fetch track audio features by its Spotify ID."""
         try:
             return self._handle_response(
-                "get", self.audio_features_url + track_id
+                "get", f"{self.audio_features_url}{track_id}"
             )
         except APIError as e:
             self._log.debug("Spotify API error: {}", e)
diff --git a/beetsplug/subsonicplaylist.py b/beetsplug/subsonicplaylist.py
index 9b4a7778cf..6c11ab9180 100644
--- a/beetsplug/subsonicplaylist.py
+++ b/beetsplug/subsonicplaylist.py
@@ -168,9 +168,7 @@ def send(self, endpoint, params=None):
         params["v"] = "1.12.0"
         params["c"] = "beets"
         resp = requests.get(
-            "{}/rest/{}?{}".format(
-                self.config["base_url"].get(), endpoint, urlencode(params)
-            ),
+            f"{self.config['base_url'].get()}/rest/{endpoint}?{urlencode(params)}",
             timeout=10,
         )
         return resp
@@ -182,5 +180,5 @@ def get_playlists(self, ids):
             for track in tracks:
                 if track not in output:
                     output[track] = ";"
-                output[track] += name + ";"
+                output[track] += f"{name};"
         return output
diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py
index ce888cb769..673cc94a84 100644
--- a/beetsplug/subsonicupdate.py
+++ b/beetsplug/subsonicupdate.py
@@ -74,7 +74,7 @@ def __create_token(self):
         # Pick the random sequence and salt the password
         r = string.ascii_letters + string.digits
         salt = "".join([random.choice(r) for _ in range(6)])
-        salted_password = password + salt
+        salted_password = f"{password}{salt}"
         token = hashlib.md5(salted_password.encode("utf-8")).hexdigest()
 
         # Put together the payload of the request to the server and the URL
@@ -101,14 +101,14 @@ def __format_url(self, endpoint):
                 context_path = ""
             url = f"http://{host}:{port}{context_path}"
 
-        return url + f"/rest/{endpoint}"
+        return f"{url}/rest/{endpoint}"
 
     def start_scan(self):
         user = self.config["user"].as_str()
         auth = self.config["auth"].as_str()
         url = self.__format_url("startScan")
-        self._log.debug("URL is {0}", url)
-        self._log.debug("auth type is {0}", self.config["auth"])
+        self._log.debug("URL is {}", url)
+        self._log.debug("auth type is {.config[auth]}", self)
 
         if auth == "token":
             salt, token = self.__create_token()
@@ -145,14 +145,15 @@ def start_scan(self):
                 and json["subsonic-response"]["status"] == "ok"
             ):
                 count = json["subsonic-response"]["scanStatus"]["count"]
-                self._log.info(f"Updating Subsonic; scanning {count} tracks")
+                self._log.info("Updating Subsonic; scanning {} tracks", count)
             elif (
                 response.status_code == 200
                 and json["subsonic-response"]["status"] == "failed"
             ):
-                error_message = json["subsonic-response"]["error"]["message"]
-                self._log.error(f"Error: {error_message}")
+                self._log.error(
+                    "Error: {[subsonic-response][error][message]}", json
+                )
             else:
-                self._log.error("Error: {0}", json)
+                self._log.error("Error: {}", json)
         except Exception as error:
-            self._log.error(f"Error: {error}")
+            self._log.error("Error: {}", error)
diff --git a/beetsplug/the.py b/beetsplug/the.py
index 802b0a3db0..664d4c01e3 100644
--- a/beetsplug/the.py
+++ b/beetsplug/the.py
@@ -23,7 +23,7 @@
 
 PATTERN_THE = "^the\\s"
 PATTERN_A = "^[a][n]?\\s"
-FORMAT = "{0}, {1}"
+FORMAT = "{}, {}"
 
 
 class ThePlugin(BeetsPlugin):
@@ -38,7 +38,7 @@ def __init__(self):
             {
                 "the": True,
                 "a": True,
-                "format": "{0}, {1}",
+                "format": "{}, {}",
                 "strip": False,
                 "patterns": [],
             }
@@ -50,11 +50,11 @@ def __init__(self):
                 try:
                     re.compile(p)
                 except re.error:
-                    self._log.error("invalid pattern: {0}", p)
+                    self._log.error("invalid pattern: {}", p)
                 else:
                     if not (p.startswith("^") or p.endswith("$")):
                         self._log.warning(
-                            'warning: "{0}" will not match string start/end',
+                            'warning: "{}" will not match string start/end',
                             p,
                         )
         if self.config["a"]:
@@ -94,7 +94,7 @@ def the_template_func(self, text):
             for p in self.patterns:
                 r = self.unthe(text, p)
                 if r != text:
-                    self._log.debug('"{0}" -> "{1}"', text, r)
+                    self._log.debug('"{}" -> "{}"', text, r)
                     break
             return r
         else:
diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py
index 5460d3fec6..651eaf3ac7 100644
--- a/beetsplug/thumbnails.py
+++ b/beetsplug/thumbnails.py
@@ -104,21 +104,21 @@ def _check_local_ok(self):
                 f"Thumbnails: ArtResizer backend {ArtResizer.shared.method}"
                 f" unexpectedly cannot write image metadata."
             )
-        self._log.debug(f"using {ArtResizer.shared.method} to write metadata")
+        self._log.debug("using {.shared.method} to write metadata", ArtResizer)
 
         uri_getter = GioURI()
         if not uri_getter.available:
             uri_getter = PathlibURI()
-        self._log.debug("using {0.name} to compute URIs", uri_getter)
+        self._log.debug("using {.name} to compute URIs", uri_getter)
         self.get_uri = uri_getter.uri
 
         return True
 
     def process_album(self, album):
         """Produce thumbnails for the album folder."""
-        self._log.debug("generating thumbnail for {0}", album)
+        self._log.debug("generating thumbnail for {}", album)
         if not album.artpath:
-            self._log.info("album {0} has no art", album)
+            self._log.info("album {} has no art", album)
             return
 
         if self.config["dolphin"]:
@@ -127,7 +127,7 @@ def process_album(self, album):
         size = ArtResizer.shared.get_size(album.artpath)
         if not size:
             self._log.warning(
-                "problem getting the picture size for {0}", album.artpath
+                "problem getting the picture size for {.artpath}", album
             )
             return
 
@@ -137,9 +137,9 @@ def process_album(self, album):
         wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR)
 
         if wrote:
-            self._log.info("wrote thumbnail for {0}", album)
+            self._log.info("wrote thumbnail for {}", album)
         else:
-            self._log.info("nothing to do for {0}", album)
+            self._log.info("nothing to do for {}", album)
 
     def make_cover_thumbnail(self, album, size, target_dir):
         """Make a thumbnail of given size for `album` and put it in
@@ -154,16 +154,16 @@ def make_cover_thumbnail(self, album, size, target_dir):
         ):
             if self.config["force"]:
                 self._log.debug(
-                    "found a suitable {1}x{1} thumbnail for {0}, "
+                    "found a suitable {0}x{0} thumbnail for {1}, "
                     "forcing regeneration",
-                    album,
                     size,
+                    album,
                 )
             else:
                 self._log.debug(
-                    "{1}x{1} thumbnail for {0} exists and is recent enough",
-                    album,
+                    "{0}x{0} thumbnail for {1} exists and is recent enough",
                     size,
+                    album,
                 )
                 return False
         resized = ArtResizer.shared.resize(size, album.artpath, target)
@@ -192,7 +192,7 @@ def add_tags(self, album, image_path):
             ArtResizer.shared.write_metadata(image_path, metadata)
         except Exception:
             self._log.exception(
-                "could not write metadata to {0}", displayable_path(image_path)
+                "could not write metadata to {}", displayable_path(image_path)
             )
 
     def make_dolphin_cover_thumbnail(self, album):
@@ -202,9 +202,9 @@ def make_dolphin_cover_thumbnail(self, album):
         artfile = os.path.split(album.artpath)[1]
         with open(syspath(outfilename), "w") as f:
             f.write("[Desktop Entry]\n")
-            f.write("Icon=./{}".format(artfile.decode("utf-8")))
+            f.write(f"Icon=./{artfile.decode('utf-8')}")
             f.close()
-        self._log.debug("Wrote file {0}", displayable_path(outfilename))
+        self._log.debug("Wrote file {}", displayable_path(outfilename))
 
 
 class URIGetter:
@@ -230,8 +230,7 @@ def copy_c_string(c_string):
     # This is a pretty dumb way to get a string copy, but it seems to
     # work. A more surefire way would be to allocate a ctypes buffer and copy
     # the data with `memcpy` or somesuch.
-    s = ctypes.cast(c_string, ctypes.c_char_p).value
-    return b"" + s
+    return ctypes.cast(c_string, ctypes.c_char_p).value
 
 
 class GioURI(URIGetter):
@@ -266,9 +265,7 @@ def uri(self, path):
         g_file_ptr = self.libgio.g_file_new_for_path(path)
         if not g_file_ptr:
             raise RuntimeError(
-                "No gfile pointer received for {}".format(
-                    displayable_path(path)
-                )
+                f"No gfile pointer received for {displayable_path(path)}"
             )
 
         try:
diff --git a/beetsplug/types.py b/beetsplug/types.py
index 9bdfdecee4..561ce68284 100644
--- a/beetsplug/types.py
+++ b/beetsplug/types.py
@@ -44,6 +44,6 @@ def _types(self):
                 mytypes[key] = types.DATE
             else:
                 raise ConfigValueError(
-                    "unknown type '{}' for the '{}' field".format(value, key)
+                    f"unknown type '{value}' for the '{key}' field"
                 )
         return mytypes
diff --git a/beetsplug/unimported.py b/beetsplug/unimported.py
index b473a346a6..20ae195a7e 100644
--- a/beetsplug/unimported.py
+++ b/beetsplug/unimported.py
@@ -34,7 +34,7 @@ def __init__(self):
     def commands(self):
         def print_unimported(lib, opts, args):
             ignore_exts = [
-                ("." + x).encode()
+                f".{x}".encode()
                 for x in self.config["ignore_extensions"].as_str_seq()
             ]
             ignore_dirs = [
diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py
index 559f0622c5..7b13cf0168 100644
--- a/beetsplug/web/__init__.py
+++ b/beetsplug/web/__init__.py
@@ -77,7 +77,7 @@ def json_generator(items, root, expand=False):
                    representation
     :returns:     generator that yields strings
     """
-    yield '{"%s":[' % root
+    yield f'{{"{root}":['
     first = True
     for item in items:
         if first:
@@ -232,9 +232,7 @@ def _get_unique_table_field_values(model, field, sort_field):
         raise KeyError
     with g.lib.transaction() as tx:
         rows = tx.query(
-            "SELECT DISTINCT '{}' FROM '{}' ORDER BY '{}'".format(
-                field, model._table, sort_field
-            )
+            f"SELECT DISTINCT '{field}' FROM '{model._table}' ORDER BY '{sort_field}'"
         )
     return [row[0] for row in rows]
 
@@ -476,7 +474,7 @@ def func(lib, opts, args):
             # Enable CORS if required.
             if self.config["cors"]:
                 self._log.info(
-                    "Enabling CORS with origin: {0}", self.config["cors"]
+                    "Enabling CORS with origin: {}", self.config["cors"]
                 )
                 from flask_cors import CORS
 
diff --git a/beetsplug/zero.py b/beetsplug/zero.py
index 05e55bfcde..bce3b1a727 100644
--- a/beetsplug/zero.py
+++ b/beetsplug/zero.py
@@ -90,10 +90,10 @@ def _set_pattern(self, field):
         Do some sanity checks then compile the regexes.
         """
         if field not in MediaFile.fields():
-            self._log.error("invalid field: {0}", field)
+            self._log.error("invalid field: {}", field)
         elif field in ("id", "path", "album_id"):
             self._log.warning(
-                "field '{0}' ignored, zeroing it would be dangerous", field
+                "field '{}' ignored, zeroing it would be dangerous", field
             )
         else:
             try:
@@ -137,7 +137,7 @@ def set_fields(self, item, tags):
 
             if match:
                 fields_set = True
-                self._log.debug("{0}: {1} -> None", field, value)
+                self._log.debug("{}: {} -> None", field, value)
                 tags[field] = None
                 if self.config["update_database"]:
                     item[field] = None
diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst
index 620e1caec0..5ee07347ff 100644
--- a/docs/dev/plugins.rst
+++ b/docs/dev/plugins.rst
@@ -384,9 +384,9 @@ Here's an example that adds a ``$disc_and_track`` field:
         number.
         """
         if item.disctotal > 1:
-            return u'%02i.%02i' % (item.disc, item.track)
+            return f"{item.disc:02d}.{item.track:02d}"
         else:
-            return u'%02i' % (item.track)
+            return f"{item.track:02d}"
 
 With this plugin enabled, templates can reference ``$disc_and_track`` as they
 can any standard metadata field.
diff --git a/docs/plugins/inline.rst b/docs/plugins/inline.rst
index 46ee3d6346..d653b6d527 100644
--- a/docs/plugins/inline.rst
+++ b/docs/plugins/inline.rst
@@ -20,8 +20,7 @@ Here are a couple of examples of expressions:
 
     item_fields:
         initial: albumartist[0].upper() + u'.'
-        disc_and_track: u'%02i.%02i' % (disc, track) if
-                        disctotal > 1 else u'%02i' % (track)
+        disc_and_track: f"{disc:02d}.{track:02d}" if disctotal > 1 else f"{track:02d}"
 
 Note that YAML syntax allows newlines in values if the subsequent lines are
 indented.
diff --git a/pyproject.toml b/pyproject.toml
index dbe8e568a7..3ba7b8b6ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -275,11 +275,15 @@ select = [
     "E", # pycodestyle
     "F", # pyflakes
     # "B", # flake8-bugbear
+    "G", # flake8-logging-format
     "I", # isort
+    "ISC", # flake8-implicit-str-concat
     "N", # pep8-naming
     "PT", # flake8-pytest-style
     # "RUF", # ruff
     # "UP", # pyupgrade
+    "UP031", # do not use percent formatting
+    "UP032", # use f-string instead of format call
     "TCH", # flake8-type-checking
     "W", # pycodestyle
 ]


Test patch:
diff --git a/beets/test/_common.py b/beets/test/_common.py
index d70f9ec803..ffb2bfd65d 100644
--- a/beets/test/_common.py
+++ b/beets/test/_common.py
@@ -153,7 +153,7 @@ def __init__(self, out=None):
         self.out = out
 
     def add(self, s):
-        self.buf.append(s + "\n")
+        self.buf.append(f"{s}\n")
 
     def close(self):
         pass
diff --git a/beets/test/helper.py b/beets/test/helper.py
index f1633c1102..85adc0825c 100644
--- a/beets/test/helper.py
+++ b/beets/test/helper.py
@@ -267,7 +267,7 @@ def create_item(self, **values):
         The item is attached to the database from `self.lib`.
         """
         values_ = {
-            "title": "t\u00eftle {0}",
+            "title": "t\u00eftle {}",
             "artist": "the \u00e4rtist",
             "album": "the \u00e4lbum",
             "track": 1,
@@ -278,7 +278,7 @@ def create_item(self, **values):
         values_["db"] = self.lib
         item = Item(**values_)
         if "path" not in values:
-            item["path"] = "audio." + item["format"].lower()
+            item["path"] = f"audio.{item['format'].lower()}"
         # mtime needs to be set last since other assignments reset it.
         item.mtime = 12345
         return item
@@ -310,7 +310,7 @@ def add_item_fixture(self, **values):
         item = self.create_item(**values)
         extension = item["format"].lower()
         item["path"] = os.path.join(
-            _common.RSRC, util.bytestring_path("min." + extension)
+            _common.RSRC, util.bytestring_path(f"min.{extension}")
         )
         item.add(self.lib)
         item.move(operation=MoveOperation.COPY)
@@ -325,7 +325,7 @@ def add_item_fixtures(self, ext="mp3", count=1):
         """Add a number of items with files to the database."""
         # TODO base this on `add_item()`
         items = []
-        path = os.path.join(_common.RSRC, util.bytestring_path("full." + ext))
+        path = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}"))
         for i in range(count):
             item = Item.from_path(path)
             item.album = f"\u00e4lbum {i}"  # Check unicode paths
@@ -372,7 +372,7 @@ def create_mediafile_fixture(self, ext="mp3", images=[]):
         specified extension a cover art image is added to the media
         file.
         """
-        src = os.path.join(_common.RSRC, util.bytestring_path("full." + ext))
+        src = os.path.join(_common.RSRC, util.bytestring_path(f"full.{ext}"))
         handle, path = mkstemp(dir=self.temp_dir)
         path = bytestring_path(path)
         os.close(handle)
@@ -570,7 +570,7 @@ def prepare_track_for_import(
         medium = MediaFile(track_path)
         medium.update(
             {
-                "album": "Tag Album" + (f" {album_id}" if album_id else ""),
+                "album": f"Tag Album{f' {album_id}' if album_id else ''}",
                 "albumartist": None,
                 "mb_albumid": None,
                 "comp": None,
@@ -831,23 +831,21 @@ def item_candidates(self, item, artist, title):
 
     def _make_track_match(self, artist, album, number):
         return TrackInfo(
-            title="Applied Track %d" % number,
-            track_id="match %d" % number,
+            title=f"Applied Track {number}",
+            track_id=f"match {number}",
             artist=artist,
             length=1,
             index=0,
         )
 
     def _make_album_match(self, artist, album, tracks, distance=0, missing=0):
-        if distance:
-            id = " " + "M" * distance
-        else:
-            id = ""
+        id = f" {'M' * distance}" if distance else ""
+
         if artist is None:
             artist = "Various Artists"
         else:
-            artist = artist.replace("Tag", "Applied") + id
-        album = album.replace("Tag", "Applied") + id
+            artist = f"{artist.replace('Tag', 'Applied')}{id}"
+        album = f"{album.replace('Tag', 'Applied')}{id}"
 
         track_infos = []
         for i in range(tracks - missing):
@@ -858,8 +856,8 @@ def _make_album_match(self, artist, album, tracks, distance=0, missing=0):
             album=album,
             tracks=track_infos,
             va=False,
-            album_id="albumid" + id,
-            artist_id="artistid" + id,
+            album_id=f"albumid{id}",
+            artist_id=f"artistid{id}",
             albumtype="soundtrack",
             data_source="match_source",
             bandcamp_album_id="bc_url",
@@ -885,7 +883,7 @@ def run(self, *args, **kwargs):
         super().run(*args, **kwargs)
 
     IMAGEHEADER: dict[str, bytes] = {
-        "image/jpeg": b"\xff\xd8\xff" + b"\x00" * 3 + b"JFIF",
+        "image/jpeg": b"\xff\xd8\xff\x00\x00\x00JFIF",
         "image/png": b"\211PNG\r\n\032\n",
         "image/gif": b"GIF89a",
         # dummy type that is definitely not a valid image content type
diff --git a/test/plugins/lyrics_pages.py b/test/plugins/lyrics_pages.py
index e1806b1679..15cb812a10 100644
--- a/test/plugins/lyrics_pages.py
+++ b/test/plugins/lyrics_pages.py
@@ -128,6 +128,7 @@ def backend(self) -> str:
         artist="Atlanta",
         track_title="Mergaitės Nori Mylėt",
         url_title="Mergaitės nori mylėt – Atlanta | Dainų Žodžiai",
+        marks=[xfail_on_ci("Expired SSL certificate")],
     ),
     LyricsPage.make(
         "https://genius.com/The-beatles-lady-madonna-lyrics",
@@ -328,34 +329,40 @@ def backend(self) -> str:
         url_title="The Beatles - Lady Madonna Lyrics",
     ),
     LyricsPage.make(
-        "https://www.lyricsmode.com/lyrics/b/beatles/lady_madonna.html",
+        "https://www.lyricsmode.com/lyrics/b/beatles/mother_natures_son.html",
         """
-        Lady Madonna, children at your feet.
-        Wonder how you manage to make ends meet.
-        Who finds the money? When you pay the rent?
-        Did you think that money was heaven sent?
+        Born a poor young country boy, Mother Nature's son
+        All day long I'm sitting singing songs for everyone
 
-        Friday night arrives without a suitcase.
-        Sunday morning creep in like a nun.
-        Mondays child has learned to tie his bootlace.
-        See how they run.
+        Sit beside a mountain stream, see her waters rise
+        Listen to the pretty sound of music as she flies
 
-        Lady Madonna, baby at your breast.
-        Wonder how you manage to feed the rest.
+        Doo doo doo doo doo doo doo doo doo doo doo
+        Doo doo doo doo doo doo doo doo doo
+        Doo doo doo
 
-        See how they run.
-        Lady Madonna, lying on the bed,
-        Listen to the music playing in your head.
+        Find me in my field of grass, Mother Nature's son
+        Swaying daises sing a lazy song beneath the sun
 
-        Tuesday afternoon is never ending.
-        Wednesday morning papers didn't come.
-        Thursday night you stockings needed mending.
-        See how they run.
+        Doo doo doo doo doo doo doo doo doo doo doo
+        Doo doo doo doo doo doo doo doo doo
+        Doo doo doo doo doo doo
+        Yeah yeah yeah
 
-        Lady Madonna, children at your feet.
-        Wonder how you manage to make ends meet.
+        Mm mm mm mm mm mm mm
+        Mm mm mm, ooh ooh ooh
+        Mm mm mm mm mm mm mm
+        Mm mm mm mm, wah wah wah
+
+        Wah, Mother Nature's son
         """,
-        url_title="Lady Madonna lyrics by The Beatles - original song full text. Official Lady Madonna lyrics, 2024 version | LyricsMode.com",  # noqa: E501
+        artist="The Beatles",
+        track_title="Mother Nature's Son",
+        url_title=(
+            "Mother Nature's Son lyrics by The Beatles - original song full"
+            " text. Official Mother Nature's Son lyrics, 2025 version"
+            " | LyricsMode.com"
+        ),
     ),
     LyricsPage.make(
         "https://www.lyricsontop.com/amy-winehouse-songs/jazz-n-blues-lyrics.html",
@@ -528,6 +535,7 @@ def backend(self) -> str:
         Wonder how you manage to make ends meet.
         """,
         url_title="The Beatles - Lady Madonna",
+        marks=[xfail_on_ci("Sweetslyrics also fails with 403 FORBIDDEN in CI")],
     ),
     LyricsPage.make(
         "https://www.tekstowo.pl/piosenka,the_beatles,lady_madonna.html",
diff --git a/test/plugins/test_art.py b/test/plugins/test_art.py
index 38f8c75597..285bb70e5a 100644
--- a/test/plugins/test_art.py
+++ b/test/plugins/test_art.py
@@ -89,11 +89,11 @@ class CAAHelper:
     MBID_RELASE = "rid"
     MBID_GROUP = "rgid"
 
-    RELEASE_URL = "coverartarchive.org/release/{}".format(MBID_RELASE)
-    GROUP_URL = "coverartarchive.org/release-group/{}".format(MBID_GROUP)
+    RELEASE_URL = f"coverartarchive.org/release/{MBID_RELASE}"
+    GROUP_URL = f"coverartarchive.org/release-group/{MBID_GROUP}"
 
-    RELEASE_URL = "https://" + RELEASE_URL
-    GROUP_URL = "https://" + GROUP_URL
+    RELEASE_URL = f"https://{RELEASE_URL}"
+    GROUP_URL = f"https://{GROUP_URL}"
 
     RESPONSE_RELEASE = """{
     "images": [
@@ -305,10 +305,8 @@ def test_precedence_amongst_correct_files(self):
 class CombinedTest(FetchImageTestCase, CAAHelper):
     ASIN = "xxxx"
     MBID = "releaseid"
-    AMAZON_URL = "https://images.amazon.com/images/P/{}.01.LZZZZZZZ.jpg".format(
-        ASIN
-    )
-    AAO_URL = "https://www.albumart.org/index_detail.php?asin={}".format(ASIN)
+    AMAZON_URL = f"https://images.amazon.com/images/P/{ASIN}.01.LZZZZZZZ.jpg"
+    AAO_URL = f"https://www.albumart.org/index_detail.php?asin={ASIN}"
 
     def setUp(self):
         super().setUp()
@@ -708,7 +706,7 @@ def mock_response(self, url, json):
     def test_fanarttv_finds_image(self):
         album = _common.Bag(mb_releasegroupid="thereleasegroupid")
         self.mock_response(
-            fetchart.FanartTV.API_ALBUMS + "thereleasegroupid",
+            f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid",
             self.RESPONSE_MULTIPLE,
         )
         candidate = next(self.source.get(album, self.settings, []))
@@ -717,7 +715,7 @@ def test_fanarttv_finds_image(self):
     def test_fanarttv_returns_no_result_when_error_received(self):
         album = _common.Bag(mb_releasegroupid="thereleasegroupid")
         self.mock_response(
-            fetchart.FanartTV.API_ALBUMS + "thereleasegroupid",
+            f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid",
             self.RESPONSE_ERROR,
         )
         with pytest.raises(StopIteration):
@@ -726,7 +724,7 @@ def test_fanarttv_returns_no_result_when_error_received(self):
     def test_fanarttv_returns_no_result_with_malformed_response(self):
         album = _common.Bag(mb_releasegroupid="thereleasegroupid")
         self.mock_response(
-            fetchart.FanartTV.API_ALBUMS + "thereleasegroupid",
+            f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid",
             self.RESPONSE_MALFORMED,
         )
         with pytest.raises(StopIteration):
@@ -736,7 +734,7 @@ def test_fanarttv_only_other_images(self):
         # The source used to fail when there were images present, but no cover
         album = _common.Bag(mb_releasegroupid="thereleasegroupid")
         self.mock_response(
-            fetchart.FanartTV.API_ALBUMS + "thereleasegroupid",
+            f"{fetchart.FanartTV.API_ALBUMS}thereleasegroupid",
             self.RESPONSE_NO_ART,
         )
         with pytest.raises(StopIteration):
diff --git a/test/plugins/test_convert.py b/test/plugins/test_convert.py
index dcf684ccc7..1452686a7d 100644
--- a/test/plugins/test_convert.py
+++ b/test/plugins/test_convert.py
@@ -49,14 +49,12 @@ def tagged_copy_cmd(self, tag):
         """
         if re.search("[^a-zA-Z0-9]", tag):
             raise ValueError(
-                "tag '{}' must only contain letters and digits".format(tag)
+                f"tag '{tag}' must only contain letters and digits"
             )
 
         # A Python script that copies the file and appends a tag.
         stub = os.path.join(_common.RSRC, b"convert_stub.py").decode("utf-8")
-        return "{} {} $source $dest {}".format(
-            shell_quote(sys.executable), shell_quote(stub), tag
-        )
+        return f"{shell_quote(sys.executable)} {shell_quote(stub)} $source $dest {tag}"
 
     def file_endswith(self, path: Path, tag: str):
         """Check the path is a file and if its content ends with `tag`."""
diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py
index c31ac7511e..e3e51042c0 100644
--- a/test/plugins/test_discogs.py
+++ b/test/plugins/test_discogs.py
@@ -82,7 +82,7 @@ def _make_release_from_positions(self, positions):
         """Return a Bag that mimics a discogs_client.Release with a
         tracklist where tracks have the specified `positions`."""
         tracks = [
-            self._make_track("TITLE%s" % i, position)
+            self._make_track(f"TITLE{i}", position)
             for (i, position) in enumerate(positions, start=1)
         ]
         return self._make_release(tracks)
diff --git a/test/plugins/test_embedart.py b/test/plugins/test_embedart.py
index 62b2bb7d10..58d2a5f636 100644
--- a/test/plugins/test_embedart.py
+++ b/test/plugins/test_embedart.py
@@ -144,9 +144,7 @@ def test_embed_art_remove_art_file(self):
         if os.path.isfile(syspath(tmp_path)):
             os.remove(syspath(tmp_path))
             self.fail(
-                "Artwork file {} was not deleted".format(
-                    displayable_path(tmp_path)
-                )
+                f"Artwork file {displayable_path(tmp_path)} was not deleted"
             )
 
     def test_art_file_missing(self):
diff --git a/test/plugins/test_ftintitle.py b/test/plugins/test_ftintitle.py
index 1dbe4a7274..572431b45b 100644
--- a/test/plugins/test_ftintitle.py
+++ b/test/plugins/test_ftintitle.py
@@ -56,21 +56,21 @@ def test_functional_not_found(self):
         assert item["title"] == "Song 1"
 
     def test_functional_custom_format(self):
-        self._ft_set_config("feat. {0}")
+        self._ft_set_config("feat. {}")
         item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice")
         self.run_command("ftintitle")
         item.load()
         assert item["artist"] == "Alice"
         assert item["title"] == "Song 1 feat. Bob"
 
-        self._ft_set_config("featuring {0}")
+        self._ft_set_config("featuring {}")
         item = self._ft_add_item("/", "Alice feat. Bob", "Song 1", "Alice")
         self.run_command("ftintitle")
         item.load()
         assert item["artist"] == "Alice"
         assert item["title"] == "Song 1 featuring Bob"
 
-        self._ft_set_config("with {0}")
+        self._ft_set_config("with {}")
         item = self._ft_add_item("/", "Alice feat Bob", "Song 1", "Alice")
         self.run_command("ftintitle")
         item.load()
@@ -78,7 +78,7 @@ def test_functional_custom_format(self):
         assert item["title"] == "Song 1 with Bob"
 
     def test_functional_keep_in_artist(self):
-        self._ft_set_config("feat. {0}", keep_in_artist=True)
+        self._ft_set_config("feat. {}", keep_in_artist=True)
         item = self._ft_add_item("/", "Alice ft Bob", "Song 1", "Alice")
         self.run_command("ftintitle")
         item.load()
diff --git a/test/plugins/test_importadded.py b/test/plugins/test_importadded.py
index 1b198b31d9..352471f9b6 100644
--- a/test/plugins/test_importadded.py
+++ b/test/plugins/test_importadded.py
@@ -65,7 +65,7 @@ def find_media_file(self, item):
             if m.title.replace("Tag", "Applied") == item.title:
                 return m
         raise AssertionError(
-            "No MediaFile found for Item " + displayable_path(item.path)
+            f"No MediaFile found for Item {displayable_path(item.path)}"
         )
 
     def test_import_album_with_added_dates(self):
@@ -117,7 +117,7 @@ def test_reimported_album_skipped(self):
         for item_path, added_after in items_added_after.items():
             assert items_added_before[item_path] == pytest.approx(
                 added_after, rel=1e-4
-            ), "reimport modified Item.added for " + displayable_path(item_path)
+            ), f"reimport modified Item.added for {displayable_path(item_path)}"
 
     def test_import_singletons_with_added_dates(self):
         self.config["import"]["singletons"] = True
@@ -157,4 +157,4 @@ def test_reimported_singletons_skipped(self):
         for item_path, added_after in items_added_after.items():
             assert items_added_before[item_path] == pytest.approx(
                 added_after, rel=1e-4
-            ), "reimport modified Item.added for " + displayable_path(item_path)
+            ), f"reimport modified Item.added for {displayable_path(item_path)}"
diff --git a/test/plugins/test_ipfs.py b/test/plugins/test_ipfs.py
index 096bc393bc..b94bd551bf 100644
--- a/test/plugins/test_ipfs.py
+++ b/test/plugins/test_ipfs.py
@@ -37,7 +37,7 @@ def test_stored_hashes(self):
             try:
                 if check_item.get("ipfs", with_album=False):
                     ipfs_item = os.fsdecode(os.path.basename(want_item.path))
-                    want_path = "/ipfs/{}/{}".format(test_album.ipfs, ipfs_item)
+                    want_path = f"/ipfs/{test_album.ipfs}/{ipfs_item}"
                     want_path = bytestring_path(want_path)
                     assert check_item.path == want_path
                     assert (
diff --git a/test/plugins/test_limit.py b/test/plugins/test_limit.py
index 12700295e0..d77e47ca84 100644
--- a/test/plugins/test_limit.py
+++ b/test/plugins/test_limit.py
@@ -42,8 +42,8 @@ def setUp(self):
 
         # a subset of tests has only `num_limit` results, identified by a
         # range filter on the track number
-        self.track_head_range = "track:.." + str(self.num_limit)
-        self.track_tail_range = "track:" + str(self.num_limit + 1) + ".."
+        self.track_head_range = f"track:..{self.num_limit}"
+        self.track_tail_range = f"track:{self.num_limit + 1}{'..'}"
 
     def test_no_limit(self):
         """Returns all when there is no limit or filter."""
@@ -82,13 +82,13 @@ def test_prefix(self):
     def test_prefix_when_correctly_ordered(self):
         """Returns the expected number with the query prefix and filter when
         the prefix portion (correctly) appears last."""
-        correct_order = self.track_tail_range + " " + self.num_limit_prefix
+        correct_order = f"{self.track_tail_range} {self.num_limit_prefix}"
         result = self.lib.items(correct_order)
         assert len(result) == self.num_limit
 
     def test_prefix_when_incorrectly_ordred(self):
         """Returns no results with the query prefix and filter when the prefix
         portion (incorrectly) appears first."""
-        incorrect_order = self.num_limit_prefix + " " + self.track_tail_range
+        incorrect_order = f"{self.num_limit_prefix} {self.track_tail_range}"
         result = self.lib.items(incorrect_order)
         assert len(result) == 0
diff --git a/test/plugins/test_mpdstats.py b/test/plugins/test_mpdstats.py
index dcaf196efa..6f5d3f3cee 100644
--- a/test/plugins/test_mpdstats.py
+++ b/test/plugins/test_mpdstats.py
@@ -77,7 +77,7 @@ def test_run_mpdstats(self, mpd_mock):
         except KeyboardInterrupt:
             pass
 
-        log.debug.assert_has_calls([call('unhandled status "{0}"', ANY)])
+        log.debug.assert_has_calls([call('unhandled status "{}"', ANY)])
         log.info.assert_has_calls(
-            [call("pause"), call("playing {0}", ANY), call("stop")]
+            [call("pause"), call("playing {}", ANY), call("stop")]
         )
diff --git a/test/plugins/test_musicbrainz.py b/test/plugins/test_musicbrainz.py
index aea05bc209..844b2ad4ef 100644
--- a/test/plugins/test_musicbrainz.py
+++ b/test/plugins/test_musicbrainz.py
@@ -99,7 +99,7 @@ def _make_release(
             for recording in tracks:
                 i += 1
                 track = {
-                    "id": "RELEASE TRACK ID %d" % i,
+                    "id": f"RELEASE TRACK ID {i}",
                     "recording": recording,
                     "position": i,
                     "number": "A1",
@@ -140,7 +140,7 @@ def _make_release(
             for recording in data_tracks:
                 i += 1
                 data_track = {
-                    "id": "RELEASE TRACK ID %d" % i,
+                    "id": f"RELEASE TRACK ID {i}",
                     "recording": recording,
                     "position": i,
                     "number": "A1",
@@ -670,17 +670,17 @@ class ArtistFlatteningTest(unittest.TestCase):
     def _credit_dict(self, suffix=""):
         return {
             "artist": {
-                "name": "NAME" + suffix,
-                "sort-name": "SORT" + suffix,
+                "name": f"NAME{suffix}",
+                "sort-name": f"SORT{suffix}",
             },
-            "name": "CREDIT" + suffix,
+            "name": f"CREDIT{suffix}",
         }
 
     def _add_alias(self, credit_dict, suffix="", locale="", primary=False):
         alias = {
-            "alias": "ALIAS" + suffix,
+            "alias": f"ALIAS{suffix}",
             "locale": locale,
-            "sort-name": "ALIASSORT" + suffix,
+            "sort-name": f"ALIASSORT{suffix}",
         }
         if primary:
             alias["primary"] = "primary"
diff --git a/test/plugins/test_parentwork.py b/test/plugins/test_parentwork.py
index 99267f6ffa..1abe257090 100644
--- a/test/plugins/test_parentwork.py
+++ b/test/plugins/test_parentwork.py
@@ -93,8 +93,7 @@ def test_normal_case_real(self):
         item = Item(
             path="/file",
             mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53",
-            parentwork_workid_current="e27bda6e-531e-36d3-9cd7-\
-                    b8ebc18e8c53",
+            parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53",
         )
         item.add(self.lib)
 
@@ -109,8 +108,7 @@ def test_force_real(self):
             path="/file",
             mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53",
             mb_parentworkid="XXX",
-            parentwork_workid_current="e27bda6e-531e-36d3-9cd7-\
-                    b8ebc18e8c53",
+            parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53",
             parentwork="whatever",
         )
         item.add(self.lib)
@@ -124,11 +122,9 @@ def test_no_force_real(self):
         self.config["parentwork"]["force"] = False
         item = Item(
             path="/file",
-            mb_workid="e27bda6e-531e-36d3-9cd7-\
-                    b8ebc18e8c53",
+            mb_workid="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53",
             mb_parentworkid="XXX",
-            parentwork_workid_current="e27bda6e-531e-36d3-9cd7-\
-                    b8ebc18e8c53",
+            parentwork_workid_current="e27bda6e-531e-36d3-9cd7-b8ebc18e8c53",
             parentwork="whatever",
         )
         item.add(self.lib)
diff --git a/test/plugins/test_play.py b/test/plugins/test_play.py
index 571af95dd8..293a50a20c 100644
--- a/test/plugins/test_play.py
+++ b/test/plugins/test_play.py
@@ -49,7 +49,7 @@ def run_and_assert(
 
         open_mock.assert_called_once_with(ANY, expected_cmd)
         expected_playlist = expected_playlist or self.item.path.decode("utf-8")
-        exp_playlist = expected_playlist + "\n"
+        exp_playlist = f"{expected_playlist}\n"
         with open(open_mock.call_args[0][0][0], "rb") as playlist:
             assert exp_playlist == playlist.read().decode("utf-8")
 
@@ -96,9 +96,7 @@ def test_use_folders(self, open_mock):
         open_mock.assert_called_once_with(ANY, open_anything())
         with open(open_mock.call_args[0][0][0], "rb") as f:
             playlist = f.read().decode("utf-8")
-        assert (
-            f"{os.path.dirname(self.item.path.decode('utf-8'))}\n" == playlist
-        )
+        assert f"{self.item.filepath.parent}\n" == playlist
 
     def test_raw(self, open_mock):
         self.config["play"]["raw"] = True
@@ -125,9 +123,7 @@ def test_skip_warning_threshold_bypass(self, open_mock):
         self.config["play"]["warning_threshold"] = 1
         self.other_item = self.add_item(title="another NiceTitle")
 
-        expected_playlist = "{}\n{}".format(
-            self.item.path.decode("utf-8"), self.other_item.path.decode("utf-8")
-        )
+        expected_playlist = f"{self.item.filepath}\n{self.other_item.filepath}"
 
         with control_stdin("a"):
             self.run_and_assert(
diff --git a/test/plugins/test_playlist.py b/test/plugins/test_playlist.py
index 9d9ce03034..a8c145696a 100644
--- a/test/plugins/test_playlist.py
+++ b/test/plugins/test_playlist.py
@@ -91,14 +91,7 @@ def test_name_query_with_absolute_paths_in_playlist(self):
         assert {i.title for i in results} == {"some item", "another item"}
 
     def test_path_query_with_absolute_paths_in_playlist(self):
-        q = "playlist:{}".format(
-            quote(
-                os.path.join(
-                    self.playlist_dir,
-                    "absolute.m3u",
-                )
-            )
-        )
+        q = f"playlist:{quote(os.path.join(self.playlist_dir, 'absolute.m3u'))}"
         results = self.lib.items(q)
         assert {i.title for i in results} == {"some item", "another item"}
 
@@ -108,14 +101,7 @@ def test_name_query_with_relative_paths_in_playlist(self):
         assert {i.title for i in results} == {"some item", "another item"}
 
     def test_path_query_with_relative_paths_in_playlist(self):
-        q = "playlist:{}".format(
-            quote(
-                os.path.join(
-                    self.playlist_dir,
-                    "relative.m3u",
-                )
-            )
-        )
+        q = f"playlist:{quote(os.path.join(self.playlist_dir, 'relative.m3u'))}"
         results = self.lib.items(q)
         assert {i.title for i in results} == {"some item", "another item"}
 
@@ -125,15 +111,7 @@ def test_name_query_with_nonexisting_playlist(self):
         assert set(results) == set()
 
     def test_path_query_with_nonexisting_playlist(self):
-        q = "playlist:{}".format(
-            quote(
-                os.path.join(
-                    self.playlist_dir,
-                    self.playlist_dir,
-                    "nonexisting.m3u",
-                )
-            )
-        )
+        q = f"playlist:{os.path.join(self.playlist_dir, 'nonexisting.m3u')!r}"
         results = self.lib.items(q)
         assert set(results) == set()
 
@@ -141,20 +119,22 @@ def test_path_query_with_nonexisting_playlist(self):
 class PlaylistTestRelativeToLib(PlaylistQueryTest, PlaylistTestCase):
     def setup_test(self):
         with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f:
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "a", "b", "c.mp3"))
-            )
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "d", "e", "f.mp3"))
-            )
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "nonexisting.mp3"))
+            f.writelines(
+                [
+                    os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n",
+                    os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n",
+                    os.path.join(self.music_dir, "nonexisting.mp3") + "\n",
+                ]
             )
 
         with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f:
-            f.write("{}\n".format(os.path.join("a", "b", "c.mp3")))
-            f.write("{}\n".format(os.path.join("d", "e", "f.mp3")))
-            f.write("{}\n".format("nonexisting.mp3"))
+            f.writelines(
+                [
+                    os.path.join("a", "b", "c.mp3") + "\n",
+                    os.path.join("d", "e", "f.mp3") + "\n",
+                    "nonexisting.mp3\n",
+                ]
+            )
 
         self.config["playlist"]["relative_to"] = "library"
 
@@ -162,20 +142,22 @@ def setup_test(self):
 class PlaylistTestRelativeToDir(PlaylistQueryTest, PlaylistTestCase):
     def setup_test(self):
         with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f:
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "a", "b", "c.mp3"))
-            )
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "d", "e", "f.mp3"))
-            )
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "nonexisting.mp3"))
+            f.writelines(
+                [
+                    os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n",
+                    os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n",
+                    os.path.join(self.music_dir, "nonexisting.mp3") + "\n",
+                ]
             )
 
         with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f:
-            f.write("{}\n".format(os.path.join("a", "b", "c.mp3")))
-            f.write("{}\n".format(os.path.join("d", "e", "f.mp3")))
-            f.write("{}\n".format("nonexisting.mp3"))
+            f.writelines(
+                [
+                    os.path.join("a", "b", "c.mp3") + "\n",
+                    os.path.join("d", "e", "f.mp3") + "\n",
+                    "nonexisting.mp3\n",
+                ]
+            )
 
         self.config["playlist"]["relative_to"] = self.music_dir
 
@@ -183,40 +165,33 @@ def setup_test(self):
 class PlaylistTestRelativeToPls(PlaylistQueryTest, PlaylistTestCase):
     def setup_test(self):
         with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f:
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "a", "b", "c.mp3"))
-            )
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "d", "e", "f.mp3"))
-            )
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "nonexisting.mp3"))
+            f.writelines(
+                [
+                    os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n",
+                    os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n",
+                    os.path.join(self.music_dir, "nonexisting.mp3") + "\n",
+                ]
             )
 
         with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f:
-            f.write(
-                "{}\n".format(
+            f.writelines(
+                [
                     os.path.relpath(
                         os.path.join(self.music_dir, "a", "b", "c.mp3"),
                         start=self.playlist_dir,
                     )
-                )
-            )
-            f.write(
-                "{}\n".format(
+                    + "\n",
                     os.path.relpath(
                         os.path.join(self.music_dir, "d", "e", "f.mp3"),
                         start=self.playlist_dir,
                     )
-                )
-            )
-            f.write(
-                "{}\n".format(
+                    + "\n",
                     os.path.relpath(
                         os.path.join(self.music_dir, "nonexisting.mp3"),
                         start=self.playlist_dir,
                     )
-                )
+                    + "\n",
+                ]
             )
 
         self.config["playlist"]["relative_to"] = "playlist"
@@ -226,20 +201,22 @@ def setup_test(self):
 class PlaylistUpdateTest:
     def setup_test(self):
         with open(os.path.join(self.playlist_dir, "absolute.m3u"), "w") as f:
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "a", "b", "c.mp3"))
-            )
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "d", "e", "f.mp3"))
-            )
-            f.write(
-                "{}\n".format(os.path.join(self.music_dir, "nonexisting.mp3"))
+            f.writelines(
+                [
+                    os.path.join(self.music_dir, "a", "b", "c.mp3") + "\n",
+                    os.path.join(self.music_dir, "d", "e", "f.mp3") + "\n",
+                    os.path.join(self.music_dir, "nonexisting.mp3") + "\n",
+                ]
             )
 
         with open(os.path.join(self.playlist_dir, "relative.m3u"), "w") as f:
-            f.write("{}\n".format(os.path.join("a", "b", "c.mp3")))
-            f.write("{}\n".format(os.path.join("d", "e", "f.mp3")))
-            f.write("{}\n".format("nonexisting.mp3"))
+            f.writelines(
+                [
+                    os.path.join("a", "b", "c.mp3") + "\n",
+                    os.path.join("d", "e", "f.mp3") + "\n",
+                    "nonexisting.mp3\n",
+                ]
+            )
 
         self.config["playlist"]["auto"] = True
         self.config["playlist"]["relative_to"] = "library"
@@ -249,9 +226,7 @@ class PlaylistTestItemMoved(PlaylistUpdateTest, PlaylistTestCase):
     def test_item_moved(self):
         # Emit item_moved event for an item that is in a playlist
         results = self.lib.items(
-            "path:{}".format(
-                quote(os.path.join(self.music_dir, "d", "e", "f.mp3"))
-            )
+            f"path:{quote(os.path.join(self.music_dir, 'd', 'e', 'f.mp3'))}"
         )
         item = results[0]
         beets.plugins.send(
@@ -265,9 +240,7 @@ def test_item_moved(self):
 
         # Emit item_moved event for an item that is not in a playlist
         results = self.lib.items(
-            "path:{}".format(
-                quote(os.path.join(self.music_dir, "x", "y", "z.mp3"))
-            )
+            f"path:{quote(os.path.join(self.music_dir, 'x', 'y', 'z.mp3'))}"
         )
         item = results[0]
         beets.plugins.send(
@@ -309,18 +282,14 @@ class PlaylistTestItemRemoved(PlaylistUpdateTest, PlaylistTestCase):
     def test_item_removed(self):
         # Emit item_removed event for an item that is in a playlist
         results = self.lib.items(
-            "path:{}".format(
-                quote(os.path.join(self.music_dir, "d", "e", "f.mp3"))
-            )
+            f"path:{quote(os.path.join(self.music_dir, 'd', 'e', 'f.mp3'))}"
         )
         item = results[0]
         beets.plugins.send("item_removed", item=item)
 
         # Emit item_removed event for an item that is not in a playlist
         results = self.lib.items(
-            "path:{}".format(
-                quote(os.path.join(self.music_dir, "x", "y", "z.mp3"))
-            )
+            f"path:{quote(os.path.join(self.music_dir, 'x', 'y', 'z.mp3'))}"
         )
         item = results[0]
         beets.plugins.send("item_removed", item=item)
diff --git a/test/plugins/test_plexupdate.py b/test/plugins/test_plexupdate.py
index f319db6cee..ab53d8c2e7 100644
--- a/test/plugins/test_plexupdate.py
+++ b/test/plugins/test_plexupdate.py
@@ -29,7 +29,7 @@ def add_response_get_music_section(self, section_name="Music"):
             "</Directory>"
             '<Directory allowSync="0" art="/:/resources/artist-fanart.jpg" '
             'filters="1" refreshing="0" thumb="/:/resources/artist.png" '
-            'key="2" type="artist" title="' + escaped_section_name + '" '
+            f'key="2" type="artist" title="{escaped_section_name}" '
             'composite="/library/sections/2/composite/1416929243" '
             'agent="com.plexapp.agents.lastfm" scanner="Plex Music Scanner" '
             'language="en" uuid="90897c95-b3bd-4778-a9c8-1f43cb78f047" '
diff --git a/test/plugins/test_plugin_mediafield.py b/test/plugins/test_plugin_mediafield.py
index 898e891ce7..84565b47b5 100644
--- a/test/plugins/test_plugin_mediafield.py
+++ b/test/plugins/test_plugin_mediafield.py
@@ -43,7 +43,7 @@
 
 class ExtendedFieldTestMixin(BeetsTestCase):
     def _mediafile_fixture(self, name, extension="mp3"):
-        name = bytestring_path(name + "." + extension)
+        name = bytestring_path(f"{name}.{extension}")
         src = os.path.join(_common.RSRC, name)
         target = os.path.join(self.temp_dir, name)
         shutil.copy(syspath(src), syspath(target))
diff --git a/test/plugins/test_random.py b/test/plugins/test_random.py
index 5bff1ee5e8..9bcf8e59bb 100644
--- a/test/plugins/test_random.py
+++ b/test/plugins/test_random.py
@@ -69,7 +69,7 @@ def experiment(field, histogram=False):
             # Print a histogram (useful for debugging).
             if histogram:
                 for i in range(len(self.items)):
-                    print("{:2d} {}".format(i, "*" * positions.count(i)))
+                    print(f"{i:2d} {'*' * positions.count(i)}")
             return self._stats(positions)
 
         mean1, stdev1, median1 = experiment("artist")
diff --git a/test/plugins/test_replaygain.py b/test/plugins/test_replaygain.py
index 091298766f..094349b259 100644
--- a/test/plugins/test_replaygain.py
+++ b/test/plugins/test_replaygain.py
@@ -204,9 +204,7 @@ def test_cli_does_not_skip_wrong_tag_type(self):
             # This test is a lot less interesting if the backend cannot write
             # both tag types.
             self.skipTest(
-                "r128 tags for opus not supported on backend {}".format(
-                    self.backend
-                )
+                f"r128 tags for opus not supported on backend {self.backend}"
             )
 
         album_rg = self._add_album(1)
@@ -263,9 +261,7 @@ def test_cli_saves_album_gain_to_file(self):
     def test_cli_writes_only_r128_tags(self):
         if not self.has_r128_support:
             self.skipTest(
-                "r128 tags for opus not supported on backend {}".format(
-                    self.backend
-                )
+                f"r128 tags for opus not supported on backend {self.backend}"
             )
 
         album = self._add_album(2, ext="opus")
@@ -299,9 +295,7 @@ def analyse(target_level):
     def test_r128_targetlevel_has_effect(self):
         if not self.has_r128_support:
             self.skipTest(
-                "r128 tags for opus not supported on backend {}".format(
-                    self.backend
-                )
+                f"r128 tags for opus not supported on backend {self.backend}"
             )
 
         album = self._add_album(1, ext="opus")
diff --git a/test/plugins/test_smartplaylist.py b/test/plugins/test_smartplaylist.py
index c8e516e8b2..d3569d8362 100644
--- a/test/plugins/test_smartplaylist.py
+++ b/test/plugins/test_smartplaylist.py
@@ -227,11 +227,10 @@ def test_playlist_update_output_extm3u(self):
         content = m3u_filepath.read_bytes()
         rmtree(syspath(dir))
 
-        assert (
-            content
-            == b"#EXTM3U\n"
-            + b"#EXTINF:300,fake artist - fake title\n"
-            + b"http://beets:8337/files/tagada.mp3\n"
+        assert content == (
+            b"#EXTM3U\n"
+            b"#EXTINF:300,fake artist - fake title\n"
+            b"http://beets:8337/files/tagada.mp3\n"
         )
 
     def test_playlist_update_output_extm3u_fields(self):
@@ -278,11 +277,10 @@ def test_playlist_update_output_extm3u_fields(self):
         content = m3u_filepath.read_bytes()
         rmtree(syspath(dir))
 
-        assert (
-            content
-            == b"#EXTM3U\n"
-            + b'#EXTINF:300 id="456" genre="Fake%20Genre",Fake Artist - fake Title\n'
-            + b"/tagada.mp3\n"
+        assert content == (
+            b"#EXTM3U\n"
+            b'#EXTINF:300 id="456" genre="Fake%20Genre",Fake Artist - fake Title\n'
+            b"/tagada.mp3\n"
         )
 
     def test_playlist_update_uri_format(self):
diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py
index 67deca36f9..86b5651b95 100644
--- a/test/plugins/test_spotify.py
+++ b/test/plugins/test_spotify.py
@@ -132,7 +132,7 @@ def test_track_for_id(self):
 
         responses.add(
             responses.GET,
-            spotify.SpotifyPlugin.track_url + "6NPVjNh8Jhru9xOmyQigds",
+            f"{spotify.SpotifyPlugin.track_url}6NPVjNh8Jhru9xOmyQigds",
             body=response_body,
             status=200,
             content_type="application/json",
@@ -145,7 +145,7 @@ def test_track_for_id(self):
 
         responses.add(
             responses.GET,
-            spotify.SpotifyPlugin.album_url + "5l3zEmMrOhOzG8d8s83GOL",
+            f"{spotify.SpotifyPlugin.album_url}5l3zEmMrOhOzG8d8s83GOL",
             body=response_body,
             status=200,
             content_type="application/json",
diff --git a/test/plugins/test_substitute.py b/test/plugins/test_substitute.py
index 48014e231a..fc3789c0b7 100644
--- a/test/plugins/test_substitute.py
+++ b/test/plugins/test_substitute.py
@@ -55,8 +55,10 @@ def test_regex_capture_group(self):
             [
                 ("King Creosote & Jon Hopkins", "King Creosote"),
                 (
-                    "Michael Hurley, The Holy Modal Rounders, Jeffrey Frederick & "
-                    + "The Clamtones",
+                    (
+                        "Michael Hurley, The Holy Modal Rounders, Jeffrey"
+                        " Frederick & The Clamtones"
+                    ),
                     "Michael Hurley",
                 ),
                 ("James Yorkston and the Athletes", "James Yorkston"),
diff --git a/test/plugins/test_web.py b/test/plugins/test_web.py
index 2ad07bbe5d..9fc3d109db 100644
--- a/test/plugins/test_web.py
+++ b/test/plugins/test_web.py
@@ -142,7 +142,7 @@ def test_get_single_item_not_found(self):
     def test_get_single_item_by_path(self):
         data_path = os.path.join(_common.RSRC, b"full.mp3")
         self.lib.add(Item.from_path(data_path))
-        response = self.client.get("/item/path/" + data_path.decode("utf-8"))
+        response = self.client.get(f"/item/path/{data_path.decode('utf-8')}")
         res_json = json.loads(response.data.decode("utf-8"))
 
         assert response.status_code == 200
@@ -152,12 +152,11 @@ def test_get_single_item_by_path_not_found_if_not_in_library(self):
         data_path = os.path.join(_common.RSRC, b"full.mp3")
         # data_path points to a valid file, but we have not added the file
         # to the library.
-        response = self.client.get("/item/path/" + data_path.decode("utf-8"))
+        response = self.client.get(f"/item/path/{data_path.decode('utf-8')}")
 
         assert response.status_code == 404
 
     def test_get_item_empty_query(self):
-        """testing item query: <empty>"""
         response = self.client.get("/item/query/")
         res_json = json.loads(response.data.decode("utf-8"))
 
@@ -165,7 +164,6 @@ def test_get_item_empty_query(self):
         assert len(res_json["items"]) == 3
 
     def test_get_simple_item_query(self):
-        """testing item query: another"""
         response = self.client.get("/item/query/another")
         res_json = json.loads(response.data.decode("utf-8"))
 
@@ -174,8 +172,7 @@ def test_get_simple_item_query(self):
         assert res_json["results"][0]["title"] == "another title"
 
     def test_query_item_string(self):
-        """testing item query: testattr:ABC"""
-        response = self.client.get("/item/query/testattr%3aABC")
+        response = self.client.get("/item/query/testattr%3aABC")  # testattr:ABC
         res_json = json.loads(response.data.decode("utf-8"))
 
         assert response.status_code == 200
@@ -183,8 +180,9 @@ def test_query_item_string(self):
         assert res_json["results"][0]["title"] == "and a third"
 
     def test_query_item_regex(self):
-        """testing item query: testattr::[A-C]+"""
-        response = self.client.get("/item/query/testattr%3a%3a[A-C]%2b")
+        response = self.client.get(
+            "/item/query/testattr%3a%3a[A-C]%2b"
+        )  # testattr::[A-C]+
         res_json = json.loads(response.data.decode("utf-8"))
 
         assert response.status_code == 200
@@ -192,8 +190,9 @@ def test_query_item_regex(self):
         assert res_json["results"][0]["title"] == "and a third"
 
     def test_query_item_regex_backslash(self):
-        # """ testing item query: testattr::\w+ """
-        response = self.client.get("/item/query/testattr%3a%3a%5cw%2b")
+        response = self.client.get(
+            "/item/query/testattr%3a%3a%5cw%2b"
+        )  # testattr::\w+
         res_json = json.loads(response.data.decode("utf-8"))
 
         assert response.status_code == 200
@@ -201,7 +200,6 @@ def test_query_item_regex_backslash(self):
         assert res_json["results"][0]["title"] == "and a third"
 
     def test_query_item_path(self):
-        # """ testing item query: path:\somewhere\a """
         """Note: path queries are special: the query item must match the path
         from the root all the way to a directory, so this matches 1 item"""
         """ Note: filesystem separators in the query must be '\' """
@@ -267,8 +265,9 @@ def test_get_album_details(self):
         assert response_track_titles == {"title", "and a third"}
 
     def test_query_album_string(self):
-        """testing query: albumtest:xy"""
-        response = self.client.get("/album/query/albumtest%3axy")
+        response = self.client.get(
+            "/album/query/albumtest%3axy"
+        )  # albumtest:xy
         res_json = json.loads(response.data.decode("utf-8"))
 
         assert response.status_code == 200
@@ -276,8 +275,9 @@ def test_query_album_string(self):
         assert res_json["results"][0]["album"] == "album"
 
     def test_query_album_artpath_regex(self):
-        """testing query: artpath::art_"""
-        response = self.client.get("/album/query/artpath%3a%3aart_")
+        response = self.client.get(
+            "/album/query/artpath%3a%3aart_"
+        )  # artpath::art_
         res_json = json.loads(response.data.decode("utf-8"))
 
         assert response.status_code == 200
@@ -285,8 +285,9 @@ def test_query_album_artpath_regex(self):
         assert res_json["results"][0]["album"] == "other album"
 
     def test_query_album_regex_backslash(self):
-        # """ testing query: albumtest::\w+ """
-        response = self.client.get("/album/query/albumtest%3a%3a%5cw%2b")
+        response = self.client.get(
+            "/album/query/albumtest%3a%3a%5cw%2b"
+        )  # albumtest::\w+
         res_json = json.loads(response.data.decode("utf-8"))
 
         assert response.status_code == 200
@@ -310,18 +311,18 @@ def test_delete_item_id(self):
         )
 
         # Check we can find the temporary item we just created
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == item_id
 
         # Delete item by id
-        response = self.client.delete("/item/" + str(item_id))
+        response = self.client.delete(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
 
         # Check the item has gone
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         assert response.status_code == 404
         # Note: if this fails, the item may still be around
         # and may cause other tests to fail
@@ -336,18 +337,18 @@ def test_delete_item_without_file(self):
         item_id = self.lib.add(Item.from_path(ipath))
 
         # Check we can find the temporary item we just created
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == item_id
 
         # Delete item by id, without deleting file
-        response = self.client.delete("/item/" + str(item_id))
+        response = self.client.delete(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
 
         # Check the item has gone
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         assert response.status_code == 404
 
         # Check the file has not gone
@@ -364,18 +365,18 @@ def test_delete_item_with_file(self):
         item_id = self.lib.add(Item.from_path(ipath))
 
         # Check we can find the temporary item we just created
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == item_id
 
         # Delete item by id, with file
-        response = self.client.delete("/item/" + str(item_id) + "?delete")
+        response = self.client.delete(f"/item/{item_id}?delete")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
 
         # Check the item has gone
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         assert response.status_code == 404
 
         # Check the file has gone
@@ -427,17 +428,17 @@ def test_delete_item_id_readonly(self):
         )
 
         # Check we can find the temporary item we just created
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == item_id
 
         # Try to delete item by id
-        response = self.client.delete("/item/" + str(item_id))
+        response = self.client.delete(f"/item/{item_id}")
         assert response.status_code == 405
 
         # Check the item has not gone
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == item_id
@@ -481,18 +482,18 @@ def test_delete_album_id(self):
         )
 
         # Check we can find the temporary album we just created
-        response = self.client.get("/album/" + str(album_id))
+        response = self.client.get(f"/album/{album_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == album_id
 
         # Delete album by id
-        response = self.client.delete("/album/" + str(album_id))
+        response = self.client.delete(f"/album/{album_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
 
         # Check the album has gone
-        response = self.client.get("/album/" + str(album_id))
+        response = self.client.get(f"/album/{album_id}")
         assert response.status_code == 404
         # Note: if this fails, the album may still be around
         # and may cause other tests to fail
@@ -543,17 +544,17 @@ def test_delete_album_id_readonly(self):
         )
 
         # Check we can find the temporary album we just created
-        response = self.client.get("/album/" + str(album_id))
+        response = self.client.get(f"/album/{album_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == album_id
 
         # Try to delete album by id
-        response = self.client.delete("/album/" + str(album_id))
+        response = self.client.delete(f"/album/{album_id}")
         assert response.status_code == 405
 
         # Check the item has not gone
-        response = self.client.get("/album/" + str(album_id))
+        response = self.client.get(f"/album/{album_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == album_id
@@ -603,7 +604,7 @@ def test_patch_item_id(self):
         )
 
         # Check we can find the temporary item we just created
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == item_id
@@ -613,7 +614,7 @@ def test_patch_item_id(self):
         # Patch item by id
         # patch_json = json.JSONEncoder().encode({"test_patch_f2": "New"}]})
         response = self.client.patch(
-            "/item/" + str(item_id), json={"test_patch_f2": "New"}
+            f"/item/{item_id}", json={"test_patch_f2": "New"}
         )
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
@@ -622,7 +623,7 @@ def test_patch_item_id(self):
         assert res_json["test_patch_f2"] == "New"
 
         # Check the update has really worked
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == item_id
@@ -647,7 +648,7 @@ def test_patch_item_id_readonly(self):
         )
 
         # Check we can find the temporary item we just created
-        response = self.client.get("/item/" + str(item_id))
+        response = self.client.get(f"/item/{item_id}")
         res_json = json.loads(response.data.decode("utf-8"))
         assert response.status_code == 200
         assert res_json["id"] == item_id
@@ -657,7 +658,7 @@ def test_patch_item_id_readonly(self):
         # Patch item by id
         # patch_json = json.JSONEncoder().encode({"test_patch_f2": "New"})
         response = self.client.patch(
-            "/item/" + str(item_id), json={"test_patch_f2": "New"}
+            f"/item/{item_id}", json={"test_patch_f2": "New"}
         )
         assert response.status_code == 405
 
@@ -670,6 +671,6 @@ def test_get_item_file(self):
         assert os.path.exists(ipath)
         item_id = self.lib.add(Item.from_path(ipath))
 
-        response = self.client.get("/item/" + str(item_id) + "/file")
+        response = self.client.get(f"/item/{item_id}/file")
 
         assert response.status_code == 200
diff --git a/test/test_art_resize.py b/test/test_art_resize.py
index 34bf810b95..0ccbb0eae6 100644
--- a/test/test_art_resize.py
+++ b/test/test_art_resize.py
@@ -150,9 +150,5 @@ def test_write_metadata_im(self, mock_util):
         metadata = {"a": "A", "b": "B"}
         im = DummyIMBackend()
         im.write_metadata("foo", metadata)
-        try:
-            command = im.convert_cmd + "foo -set a A -set b B foo".split()
-            mock_util.command_output.assert_called_once_with(command)
-        except AssertionError:
-            command = im.convert_cmd + "foo -set b B -set a A foo".split()
-            mock_util.command_output.assert_called_once_with(command)
+        command = [*im.convert_cmd, *"foo -set a A -set b B foo".split()]
+        mock_util.command_output.assert_called_once_with(command)
diff --git a/test/test_datequery.py b/test/test_datequery.py
index 1063a62c1f..d73fca45f3 100644
--- a/test/test_datequery.py
+++ b/test/test_datequery.py
@@ -186,37 +186,37 @@ def setUp(self):
 
     def test_relative(self):
         for timespan in ["d", "w", "m", "y"]:
-            query = DateQuery("added", "-4" + timespan + "..+4" + timespan)
+            query = DateQuery("added", f"-4{timespan}..+4{timespan}")
             matched = self.lib.items(query)
             assert len(matched) == 1
 
     def test_relative_fail(self):
         for timespan in ["d", "w", "m", "y"]:
-            query = DateQuery("added", "-2" + timespan + "..-1" + timespan)
+            query = DateQuery("added", f"-2{timespan}..-1{timespan}")
             matched = self.lib.items(query)
             assert len(matched) == 0
 
     def test_start_relative(self):
         for timespan in ["d", "w", "m", "y"]:
-            query = DateQuery("added", "-4" + timespan + "..")
+            query = DateQuery("added", f"-4{timespan}..")
             matched = self.lib.items(query)
             assert len(matched) == 1
 
     def test_start_relative_fail(self):
         for timespan in ["d", "w", "m", "y"]:
-            query = DateQuery("added", "4" + timespan + "..")
+            query = DateQuery("added", f"4{timespan}..")
             matched = self.lib.items(query)
             assert len(matched) == 0
 
     def test_end_relative(self):
         for timespan in ["d", "w", "m", "y"]:
-            query = DateQuery("added", "..+4" + timespan)
+            query = DateQuery("added", f"..+4{timespan}")
             matched = self.lib.items(query)
             assert len(matched) == 1
 
     def test_end_relative_fail(self):
         for timespan in ["d", "w", "m", "y"]:
-            query = DateQuery("added", "..-4" + timespan)
+            query = DateQuery("added", f"..-4{timespan}")
             matched = self.lib.items(query)
             assert len(matched) == 0
 
diff --git a/test/test_dbcore.py b/test/test_dbcore.py
index 3f9a9d45e6..b2ec2e968d 100644
--- a/test/test_dbcore.py
+++ b/test/test_dbcore.py
@@ -256,7 +256,7 @@ def test_mutate_increase_revision(self):
     def test_query_no_increase_revision(self):
         old_rev = self.db.revision
         with self.db.transaction() as tx:
-            tx.query("PRAGMA table_info(%s)" % ModelFixture1._table)
+            tx.query(f"PRAGMA table_info({ModelFixture1._table})")
         assert self.db.revision == old_rev
 
 
diff --git a/test/test_library.py b/test/test_library.py
index 35791bad72..7c05290017 100644
--- a/test/test_library.py
+++ b/test/test_library.py
@@ -1033,7 +1033,7 @@ def setUp(self):
 
     def test_art_filename_respects_setting(self):
         art = self.ai.art_destination("something.jpg")
-        new_art = bytestring_path("%sartimage.jpg" % os.path.sep)
+        new_art = bytestring_path(f"{os.path.sep}artimage.jpg")
         assert new_art in art
 
     def test_art_path_in_item_dir(self):
diff --git a/test/test_logging.py b/test/test_logging.py
index 1859ea2dd5..aee0bd61bb 100644
--- a/test/test_logging.py
+++ b/test/test_logging.py
@@ -42,7 +42,7 @@ def test_str_format_logging(self):
         logger.addHandler(handler)
         logger.propagate = False
 
-        logger.warning("foo {0} {bar}", "oof", bar="baz")
+        logger.warning("foo {} {bar}", "oof", bar="baz")
         handler.flush()
         assert stream.getvalue(), "foo oof baz"
 
@@ -58,9 +58,9 @@ def __init__(self):
                 self.register_listener("dummy_event", self.listener)
 
             def log_all(self, name):
-                self._log.debug("debug " + name)
-                self._log.info("info " + name)
-                self._log.warning("warning " + name)
+                self._log.debug("debug {}", name)
+                self._log.info("info {}", name)
+                self._log.warning("warning {}", name)
 
             def commands(self):
                 cmd = ui.Subcommand("dummy")
@@ -172,9 +172,9 @@ def __init__(self, test_case):
             self.t1_step = self.t2_step = 0
 
         def log_all(self, name):
-            self._log.debug("debug " + name)
-            self._log.info("info " + name)
-            self._log.warning("warning " + name)
+            self._log.debug("debug {}", name)
+            self._log.info("info {}", name)
+            self._log.warning("warning {}", name)
 
         def listener1(self):
             try:
diff --git a/test/test_ui.py b/test/test_ui.py
index 664323e2a1..534d0e4665 100644
--- a/test/test_ui.py
+++ b/test/test_ui.py
@@ -1020,7 +1020,7 @@ def test_command_line_option_relative_to_working_dir(self):
 
     def test_cli_config_file_loads_plugin_commands(self):
         with open(self.cli_config_path, "w") as file:
-            file.write("pluginpath: %s\n" % _common.PLUGINPATH)
+            file.write(f"pluginpath: {_common.PLUGINPATH}\n")
             file.write("plugins: test")
 
         self.run_command("--config", self.cli_config_path, "plugin", lib=None)
@@ -1257,11 +1257,10 @@ def test_album_data_change_wrap_newline(self):
         with patch("beets.ui.commands.ui.term_width", return_value=30):
             # Test newline layout
             config["ui"]["import"]["layout"] = "newline"
-            long_name = "another artist with a" + (" very" * 10) + " long name"
+            long_name = f"another artist with a{' very' * 10} long name"
             msg = self._show_change(
                 cur_artist=long_name, cur_album="another album"
             )
-            # _common.log.info("Message:{}".format(msg))
             assert "artist: another artist" in msg
             assert "  -> the artist" in msg
             assert "another album -> the album" not in msg
@@ -1271,7 +1270,7 @@ def test_item_data_change_wrap_column(self):
         with patch("beets.ui.commands.ui.term_width", return_value=54):
             # Test Column layout
             config["ui"]["import"]["layout"] = "column"
-            long_title = "a track with a" + (" very" * 10) + " long name"
+            long_title = f"a track with a{' very' * 10} long name"
             self.items[0].title = long_title
             msg = self._show_change()
             assert "(#1) a track (1:00) -> (#1) the title (0:00)" in msg
@@ -1280,7 +1279,7 @@ def test_item_data_change_wrap_newline(self):
         # Patch ui.term_width to force wrapping
         with patch("beets.ui.commands.ui.term_width", return_value=30):
             config["ui"]["import"]["layout"] = "newline"
-            long_title = "a track with a" + (" very" * 10) + " long name"
+            long_title = f"a track with a{' very' * 10} long name"
             self.items[0].title = long_title
             msg = self._show_change()
             assert "(#1) a track with" in msg