From 177e997cb05c2364b827df4241296f824be04bfb Mon Sep 17 00:00:00 2001 From: Henry Oberholtzer Date: Tue, 13 Jan 2026 10:39:56 -0800 Subject: [PATCH] Add future annotations, add vinyl track index parsing, simplify docs --- beetsplug/fromfilename.py | 61 ++++++++++++++++++++++++++++--- docs/plugins/fromfilename.rst | 14 +++---- test/plugins/test_fromfilename.py | 31 ++++++++++++++++ 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index a95d63b19..9960dfe97 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -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) diff --git a/docs/plugins/fromfilename.rst b/docs/plugins/fromfilename.rst index 98ea92f94..c4f15a59e 100644 --- a/docs/plugins/fromfilename.rst +++ b/docs/plugins/fromfilename.rst @@ -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`` diff --git a/test/plugins/test_fromfilename.py b/test/plugins/test_fromfilename.py index 4f09deee1..7d264e396 100644 --- a/test/plugins/test_fromfilename.py +++ b/test/plugins/test_fromfilename.py @@ -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",