Add future annotations, add vinyl track index parsing, simplify docs

This commit is contained in:
Henry Oberholtzer 2026-01-13 10:39:56 -08:00
parent 4cd29f9a85
commit 177e997cb0
3 changed files with 94 additions and 12 deletions

View file

@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2016, Jan-Erik Dahlin
# Copyright 2016, Jan-Erik Dahlin, Henry Oberholtzer.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@ -16,18 +16,23 @@
(possibly also extract track and artist)
"""
from __future__ import annotations
import re
from collections.abc import Iterator, MutableMapping, ValuesView
from datetime import datetime
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING
from beets import config
from beets.importer import ImportSession, ImportTask
from beets.library import Item
from beets.plugins import BeetsPlugin
from beets.util import displayable_path
if TYPE_CHECKING:
from beets.importer import ImportSession, ImportTask
from beets.library import Item
# Filename field extraction patterns
RE_TRACK_INFO = re.compile(
r"""
@ -52,6 +57,8 @@ RE_TRACK_INFO = re.compile(
re.VERBOSE | re.IGNORECASE,
)
RE_ALPHANUM_INDEX = re.compile(r"^[A-Z]{1,2}\d{,2}\b")
# Catalog number extraction pattern
RE_CATALOGNUM = re.compile(
r"""
@ -180,14 +187,14 @@ class FromFilenamePlugin(BeetsPlugin):
Once the information has been obtained and checked, it
is applied to the items to improve later metadata lookup.
"""
# Create the list of items to process
# Retrieve the list of items to process
items: list[Item] = task.items
# If there's no missing data to parse
if not self._check_missing_data(items):
return
# Retrieve the path characteristics to check
parent_folder, item_filenames = self._get_path_strings(items)
album_matches = self._parse_album_info(parent_folder)
@ -259,6 +266,8 @@ class FromFilenamePlugin(BeetsPlugin):
self, item_filenames: dict[Item, str]
) -> dict[Item, FilenameMatch]:
track_matches: dict[Item, FilenameMatch] = {}
# Check for alphanumeric indices
self._parse_alphanumeric_index(item_filenames)
for item, filename in item_filenames.items():
if m := self._check_user_matches(filename, self.file_patterns):
track_matches[item] = m
@ -267,6 +276,48 @@ class FromFilenamePlugin(BeetsPlugin):
track_matches[item] = match
return track_matches
@staticmethod
def _parse_alphanumeric_index(item_filenames: dict[Item, str]) -> None:
"""Before continuing to regular track matches, see if an alphanumeric
tracklist can be extracted. "A1, B1, B2" Sometimes these are followed
by a dash or dot and must be anchored to the start of the string.
All matched patterns are extracted, and replaced with integers.
Discs are not accounted for.
"""
def match_index(filename: str) -> str:
m = RE_ALPHANUM_INDEX.match(filename)
if not m:
return ""
else:
return m.group()
# Extract matches for alphanumeric indexes
indexes: list[tuple[str, Item]] = [
(match_index(filename), item)
for item, filename in item_filenames.items()
]
# If all the tracks do not start with a vinyl index, abort
if not all([i[0] for i in indexes]):
return
# Utility function for sorting
def index_key(x: tuple[str, Item]):
return x[0]
# If all have match, sort by the matched strings
indexes.sort(key=index_key)
# Iterate through all the filenames
for index, pair in enumerate(indexes):
match, item = pair
# Substitute the alnum index with an integer
new_filename = item_filenames[item].replace(
match, str(index + 1), 1
)
item_filenames[item] = new_filename
@staticmethod
def _parse_track_info(text: str) -> FilenameMatch:
match = RE_TRACK_INFO.match(text)

View file

@ -57,16 +57,16 @@ Default
.. conf:: patterns
Users can specify patterns to improve the efficacy of the plugin. Patterns can
be specified as ``file`` or ``folder`` patterns. ``file`` patterns are checked
against the filename. ``folder`` patterns are checked against the parent folder
of the file.
Users can specify patterns to expand the set of filenames that can
be recognized by the plugin. Patterns can be specified as ``file``
or ``folder`` patterns. ``file`` patterns are checked against the filename.
``folder`` patterns are checked against the parent folder of the file.
If ``fromfilename`` can't match the entire string to the given pattern, it will
If ``fromfilename`` can't match the entire string to one of the given pattern, it will
fall back to the default pattern.
The following custom patterns will match this path and retrieve the specified
fields.
For example, the following custom patterns will match this path and folder,
and retrieve the specified fields.
``/music/James Lawson - 841689 (2004)/Coming Up - James Lawson & Andy Farley.mp3``

View file

@ -712,6 +712,37 @@ class TestFromFilename(PluginMixin):
assert res.year == expected.year
assert res.title == expected.title
@pytest.mark.parametrize(
"expected",
[
(
mock_item(path="/temp/A - track.wav", track=1),
mock_item(path="/temp/B - track.wav", track=2),
mock_item(path="/temp/C - track.wav", track=3),
),
# Test with numbers
(
mock_item(path="/temp/A1 - track.wav", track=1),
mock_item(path="/temp/A2 - track.wav", track=2),
mock_item(path="/temp/B1 - track.wav", track=3),
),
# Test out of order
(
mock_item(path="/temp/Z - track.wav", track=3),
mock_item(path="/temp/X - track.wav", track=1),
mock_item(path="/temp/Y - track.wav", track=2),
),
],
)
def test_alphanumeric_index(self, expected):
"""Test parsing an alphanumeric index string."""
task = mock_task([mock_item(path=item.path) for item in expected])
f = FromFilenamePlugin()
f.filename_task(task, Session())
assert task.items[0].track == expected[0].track
assert task.items[1].track == expected[1].track
assert task.items[2].track == expected[2].track
def test_no_changes(self):
item = mock_item(
path="/Folder/File.wav",