Final types, docs, ignore directories

This commit is contained in:
Henry Oberholtzer 2026-01-07 15:08:38 -08:00
parent 3676c39d65
commit 32672054d4
3 changed files with 120 additions and 24 deletions

View file

@ -17,7 +17,7 @@
"""
import re
from collections.abc import MutableMapping
from collections.abc import Iterator, MutableMapping, ValuesView
from datetime import datetime
from functools import cached_property
from pathlib import Path
@ -118,10 +118,10 @@ class FilenameMatch(MutableMapping[str, str | None]):
if value is not None:
self._matches[key.lower()] = str(value).strip()
def __getitem__(self, key) -> str | None:
def __getitem__(self, key: str) -> str | None:
return self._matches.get(key, None)
def __iter__(self):
def __iter__(self) -> Iterator[str]:
return iter(self._matches)
def __len__(self) -> int:
@ -134,7 +134,7 @@ class FilenameMatch(MutableMapping[str, str | None]):
def __delitem__(self, key: str) -> None:
del self._matches[key]
def values(self):
def values(self) -> ValuesView[str | None]:
return self._matches.values()
@ -155,23 +155,22 @@ class FromFilenamePlugin(BeetsPlugin):
"year",
],
"patterns": {"folder": [], "file": []},
# TODO: Add ignore parent folder
"ignore_dirs": [],
}
)
self.fields = set(self.config["fields"].as_str_seq())
# Evaluate the user patterns to expand the fields
self.file_patterns = self._user_pattern_to_regex(
self.config["patterns"]["file"].as_str_seq()
)
self.folder_patterns = self._user_pattern_to_regex(
self.config["patterns"]["folder"].as_str_seq()
)
self.register_listener("import_task_start", self.filename_task)
@cached_property
def file_patterns(self) -> list[re.Pattern[str]]:
return self._user_pattern_to_regex(
self.config["patterns"]["file"].as_str_seq()
)
@cached_property
def folder_patterns(self) -> list[re.Pattern[str]]:
return self._user_pattern_to_regex(
self.config["patterns"]["folder"].as_str_seq()
)
def ignored_directories(self) -> set[str]:
return set([p.lower() for p in self.config["ignore_dirs"].as_str_seq()])
def filename_task(self, task: ImportTask, session: ImportSession) -> None:
"""Examines all files in the given import task for any missing
@ -184,8 +183,10 @@ class FromFilenamePlugin(BeetsPlugin):
items: list[Item] = task.items
# TODO: Check each of the fields to see if any are missing
# information on the file.
# If there's no missing data to parse
if not self._check_missing_data(items):
return
parent_folder, item_filenames = self._get_path_strings(items)
album_matches = self._parse_album_info(parent_folder)
@ -196,6 +197,31 @@ class FromFilenamePlugin(BeetsPlugin):
# Apply the information
self._apply_matches(album_matches, track_matches)
def _check_missing_data(self, items: list[Item]) -> bool:
"""Look for what fields are missing data on the items.
Compare each field in self.fields to the fields on the
item.
If all items have it, remove it from fields.
If any items are missing it, keep it on the fields.
If no fields are detect that need to be processed,
return false to shortcut the plugin.
"""
remove = set()
for field in self.fields:
# If any field is a bad field
if any([True for item in items if self._bad_field(item[field])]):
continue
else:
remove.add(field)
self.fields = self.fields - remove
# If all fields have been removed, there is nothing to do
if not len(self.fields):
return False
return True
def _user_pattern_to_regex(
self, patterns: list[str]
) -> list[re.Pattern[str]]:
@ -208,8 +234,9 @@ class FromFilenamePlugin(BeetsPlugin):
if (regexp := self._parse_user_pattern_strings(p))
]
@staticmethod
def _get_path_strings(items: list[Item]) -> tuple[str, dict[Item, str]]:
def _get_path_strings(
self, items: list[Item]
) -> tuple[str, dict[Item, str]]:
parent_folder: str = ""
filenames: dict[Item, str] = {}
for item in items:
@ -218,6 +245,8 @@ class FromFilenamePlugin(BeetsPlugin):
filenames[item] = filename
if not parent_folder:
parent_folder = path.parent.stem
if parent_folder.lower() in self.ignored_directories:
parent_folder = ""
return parent_folder, filenames
def _check_user_matches(
@ -314,7 +343,7 @@ class FromFilenamePlugin(BeetsPlugin):
for key in match.keys():
if key in self.fields:
old_value = item.get(key)
new_value = match[key] # type: ignore
new_value = match[key]
if self._bad_field(old_value) and new_value:
found_data[key] = new_value
self._log.info(f"Item updated with: {found_data.items()}")
@ -436,7 +465,7 @@ class FromFilenamePlugin(BeetsPlugin):
identified.
"""
def swap_artist_title(tracks: list[FilenameMatch]):
def swap_artist_title(tracks: list[FilenameMatch]) -> None:
for track in tracks:
artist = track["title"]
track["title"] = track["artist"]
@ -475,7 +504,7 @@ class FromFilenamePlugin(BeetsPlugin):
@staticmethod
def _equal_fields(dictionaries: list[FilenameMatch], field: str) -> bool:
"""Checks if all values of a field on a dictionary match."""
return len(set(d[field] for d in dictionaries)) <= 1 # type: ignore
return len(set(d[field] for d in dictionaries)) <= 1
@staticmethod
def _bad_field(field: str | int) -> bool:

View file

@ -7,7 +7,8 @@ but where the filenames contain useful information like the artist and title.
When you attempt to import a track that's missing a title, this plugin will look
at the track's filename and parent folder, and guess a number of fields.
The extracted information will be used to search for metadata and match track ordering.
The extracted information will be used to search for metadata and match track
ordering.
To use the ``fromfilename`` plugin, enable it in your configuration (see
:ref:`using-plugins`).
@ -38,11 +39,21 @@ Default
patterns:
file: []
folder: []
ignore_dirs:
.. conf:: fields
:default: [ artist, album, albumartist, catalognum, disc, media, title, track, year ]
The fields the plugin will guess with its default pattern matching. If a field is specified in a user pattern, that field does not need to be present on this list to be applied. If you only want the plugin contribute the track title and artist, you would put ``[title, artist]``.
The fields the plugin will guess with its default pattern matching.
By default, the plugin is configured to match all fields its default
patterns are capable of matching.
If a field is specified in a user pattern, that field does not need
to be present on this list to be applied.
If you only want the plugin to contribute the track title and artist,
you would put ``[title, artist]``.
.. conf:: patterns
@ -67,3 +78,9 @@ Default
file:
- "$title - $artist"
.. conf:: ignore_dirs
:default: []
Specify parent directory names that will not be searched for album
information. Useful if you use a regular directory for importing
single files.

View file

@ -13,6 +13,9 @@
"""Tests for the fromfilename plugin."""
from copy import deepcopy
from unittest.mock import patch
import pytest
from beets.importer.tasks import ImportTask, SingletonImportTask
@ -287,6 +290,7 @@ def test_parse_user_pattern_strings(string, pattern):
class TestFromFilename(PluginMixin):
plugin = "fromfilename"
preload_plugin = False
@pytest.mark.parametrize(
"expected_item",
@ -707,3 +711,49 @@ class TestFromFilename(PluginMixin):
assert res.catalognum == expected.catalognum
assert res.year == expected.year
assert res.title == expected.title
def test_no_changes(self):
item = mock_item(
path="/Folder/File.wav",
albumartist="AlbumArtist",
artist="Artist",
title="Title",
)
fields = ["artist", "title", "albumartist"]
task = mock_task([item])
with self.configure_plugin({"fields": fields}):
with patch.object(FromFilenamePlugin, "_get_path_strings") as mock:
f = FromFilenamePlugin()
f.filename_task(task, Session())
mock.assert_not_called()
def test_changes_missing_values(self):
item = mock_item(
path="/Folder/File.wav",
albumartist="AlbumArtist",
artist="Artist",
title="Title",
)
item2 = deepcopy(item)
item2.title = ""
fields = ["artist", "title", "albumartist"]
task = mock_task([item, item2])
with self.configure_plugin({"fields": fields}):
with patch.object(
FromFilenamePlugin,
"_get_path_strings",
return_value=("mock", {item: "mock"}),
) as mock:
f = FromFilenamePlugin()
f.filename_task(task, Session())
assert len(f.fields) == 1
assert "title" in f.fields
mock.assert_called()
def test_ignored_directories(self):
ignored = "Incoming"
item = mock_item(path="/tmp/" + ignored + "/01 - File.wav")
with self.configure_plugin({"ignore_dirs": [ignored]}):
f = FromFilenamePlugin()
parent_folder, _ = f._get_path_strings([item])
assert parent_folder == ""