Refactor to use mutable mapping.

This commit is contained in:
Henry 2026-01-06 22:01:37 -08:00
parent e147a21894
commit 4672ee0487
2 changed files with 224 additions and 217 deletions

View file

@ -17,12 +17,10 @@
"""
import re
from collections.abc import MutableMapping
from datetime import datetime
from functools import cached_property
from pathlib import Path
from typing import TypedDict
from typing_extensions import NotRequired
from beets import config
from beets.importer import ImportSession, ImportTask
@ -113,20 +111,32 @@ RE_SPLIT = re.compile(r"[\-\_]+")
RE_BRACKETS = re.compile(r"[\(\[\{].*?[\)\]\}]")
class TrackMatch(TypedDict):
disc: str | None
track: str | None
by: NotRequired[str | None]
artist: str | None
title: str | None
class FilenameMatch(MutableMapping[str, str | None]):
def __init__(self, matches: dict[str, str] = {}) -> None:
self._matches: dict[str, str] = {}
for key, value in matches.items():
if value is not None:
self._matches[key.lower()] = str(value).strip()
def __getitem__(self, key) -> str | None:
return self._matches.get(key, None)
def __iter__(self):
return iter(self._matches)
def __len__(self) -> int:
return len(self._matches)
def __setitem__(self, key: str, value: str | None) -> None:
if value:
self._matches[key] = value.strip()
def __delitem__(self, key: str) -> None:
del self._matches[key]
def values(self):
return self._matches.values()
class AlbumMatch(TypedDict):
albumartist: str | None
album: str | None
year: str | None
catalognum: str | None
media: str | None
class FromFilenamePlugin(BeetsPlugin):
@ -198,7 +208,6 @@ class FromFilenamePlugin(BeetsPlugin):
@staticmethod
def _escape(text: str) -> str:
# escape brackets for fstring logs
# TODO: Create an issue for brackets in logger
return re.sub("}", "}}", re.sub("{", "{{", text))
@staticmethod
@ -214,15 +223,15 @@ class FromFilenamePlugin(BeetsPlugin):
return parent_folder, filenames
def _check_user_matches(self, text: str,
patterns: list[re.Pattern[str]]) -> dict[str, str]:
patterns: list[re.Pattern[str]]) -> FilenameMatch:
for p in patterns:
if (usermatch := p.fullmatch(text)):
return usermatch.groupdict()
return FilenameMatch(usermatch.groupdict())
return None
def _build_track_matches(self,
item_filenames: dict[Item, str]) -> dict[Item, dict[str, str]]:
track_matches: dict[Item, dict[str, str]] = {}
item_filenames: dict[Item, str]) -> dict[Item, FilenameMatch]:
track_matches: dict[Item, FilenameMatch] = {}
for item, filename in item_filenames.items():
if (m := self._check_user_matches(filename, self.file_patterns)):
track_matches[item] = m
@ -232,53 +241,32 @@ class FromFilenamePlugin(BeetsPlugin):
return track_matches
@staticmethod
def _parse_track_info(text: str) -> TrackMatch:
trackmatch: TrackMatch = {
"disc": None,
"track": None,
"by": None,
"artist": None,
"title": None,
}
def _parse_track_info(text: str) -> FilenameMatch:
match = RE_TRACK_INFO.match(text)
assert match is not None
if disc := match.group("disc"):
trackmatch["disc"] = str(disc)
if track := match.group("track"):
trackmatch["track"] = str(track).strip()
if by := match.group("by"):
trackmatch["by"] = str(by)
if artist := match.group("artist"):
trackmatch["artist"] = str(artist).strip()
if title := match.group("title"):
trackmatch["title"] = str(title).strip()
trackmatch = FilenameMatch(match.groupdict())
# if the phrase "by" is matched, swap artist and title
if trackmatch["by"]:
artist = trackmatch["title"]
trackmatch["title"] = trackmatch["artist"]
trackmatch["artist"] = artist
# remove that key
del trackmatch["by"]
# remove that key
del trackmatch["by"]
# if all fields except `track` are none
# set title to track number as well
# we can't be sure if it's actually the track number
# or track title
if set(trackmatch.values()) == {None, trackmatch["track"]}:
trackmatch["title"] = trackmatch["track"]
track = match.group("track")
if set(trackmatch.values()) == {track}:
trackmatch["title"] = track
return trackmatch
def _parse_album_info(self, text: str) -> dict[str, str]:
def _parse_album_info(self, text: str) -> FilenameMatch:
# Check if a user pattern matches
if (m := self._check_user_matches(text, self.folder_patterns)):
return m
matches: AlbumMatch = {
"albumartist": None,
"album": None,
"year": None,
"catalognum": None,
"media": None,
}
matches = FilenameMatch()
# Start with the extra fields to make parsing
# the album artist and artist field easier
year, span = self._parse_year(text)
@ -313,7 +301,7 @@ class FromFilenamePlugin(BeetsPlugin):
return matches
def _apply_matches(
self, album_match: AlbumMatch, track_matches: dict[Item, TrackMatch]
self, album_match: FilenameMatch, track_matches: dict[Item, FilenameMatch]
) -> None:
"""Apply all valid matched fields to all items in the match dictionary."""
match = album_match
@ -434,7 +422,7 @@ class FromFilenamePlugin(BeetsPlugin):
return text
def _sanity_check_matches(
self, album_match: AlbumMatch, track_matches: dict[Item, TrackMatch]
self, album_match: FilenameMatch, track_matches: dict[Item, FilenameMatch]
) -> None:
"""Check to make sure data is coherent between
track and album matches. Largely looking to see
@ -442,7 +430,7 @@ class FromFilenamePlugin(BeetsPlugin):
identified.
"""
def swap_artist_title(tracks: list[TrackMatch]):
def swap_artist_title(tracks: list[FilenameMatch]):
for track in tracks:
artist = track["title"]
track["title"] = track["artist"]
@ -454,7 +442,7 @@ class FromFilenamePlugin(BeetsPlugin):
if len(track_matches) < 2:
return
tracks: list[TrackMatch] = list(track_matches.values())
tracks: list[FilenameMatch] = list(track_matches.values())
album_artist = album_match["albumartist"]
one_artist = self._equal_fields(tracks, "artist")
one_title = self._equal_fields(tracks, "title")
@ -479,7 +467,7 @@ class FromFilenamePlugin(BeetsPlugin):
return
@staticmethod
def _equal_fields(dictionaries: list[TrackMatch], field: str) -> bool:
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

View file

@ -15,10 +15,10 @@
import pytest
from beets.importer.tasks import ImportTask, SingletonImportTask
from beets.library import Item
from beets.test.helper import PluginMixin
from beets.importer.tasks import ImportTask, SingletonImportTask
from beetsplug.fromfilename import FromFilenamePlugin
from beetsplug.fromfilename import FilenameMatch, FromFilenamePlugin
class Session:
@ -47,68 +47,79 @@ def mock_task(items):
@pytest.mark.parametrize(
"text,matchgroup",
[
("3", {"disc": None, "track": "3", "artist": None, "title": "3"}),
("04", {"disc": None, "track": "04", "artist": None, "title": "04"}),
("6.", {"disc": None, "track": "6", "artist": None, "title": "6"}),
("3.5", {"disc": "3", "track": "5", "artist": None, "title": None}),
("1-02", {"disc": "1", "track": "02", "artist": None, "title": None}),
("100-4", {"disc": "100", "track": "4", "artist": None, "title": None}),
("3", FilenameMatch({"track": "3", "title": "3"})),
("04", FilenameMatch({"track": "04", "title": "04"})),
("6.", FilenameMatch({"track": "6", "title": "6"})),
("3.5", FilenameMatch({"disc": "3", "track": "5"})),
("1-02", FilenameMatch({"disc": "1", "track": "02"})),
("100-4", FilenameMatch({"disc": "100", "track": "4"})),
(
"04.Title",
{"disc": None, "track": "04", "artist": None, "title": "Title"},
FilenameMatch({"track": "04", "title": "Title"}),
),
(
"5_-_Title",
{"disc": None, "track": "5", "artist": None, "title": "Title"},
FilenameMatch({"track": "5", "title": "Title"}),
),
(
"1-02 Title",
{"disc": "1", "track": "02", "artist": None, "title": "Title"},
FilenameMatch({"disc": "1", "track": "02", "title": "Title"}),
),
(
"3.5 - Title",
{"disc": "3", "track": "5", "artist": None, "title": "Title"},
FilenameMatch({"disc": "3", "track": "5", "title": "Title"}),
),
(
"5_-_Artist_-_Title",
{"disc": None, "track": "5", "artist": "Artist", "title": "Title"},
FilenameMatch({"track": "5", "artist": "Artist", "title": "Title"}),
),
(
"3-8- Artist-Title",
{"disc": "3", "track": "8", "artist": "Artist", "title": "Title"},
FilenameMatch(
{
"disc": "3",
"track": "8",
"artist": "Artist",
"title": "Title",
}
),
),
(
"4-3 - Artist Name - Title",
{
"disc": "4",
"track": "3",
"artist": "Artist Name",
"title": "Title",
},
FilenameMatch(
{
"disc": "4",
"track": "3",
"artist": "Artist Name",
"title": "Title",
}
),
),
(
"4-3_-_Artist_Name_-_Title",
{
"disc": "4",
"track": "3",
"artist": "Artist_Name",
"title": "Title",
},
FilenameMatch(
{
"disc": "4",
"track": "3",
"artist": "Artist_Name",
"title": "Title",
}
),
),
(
"6 Title by Artist",
{"disc": None, "track": "6", "artist": "Artist", "title": "Title"},
FilenameMatch({"track": "6", "artist": "Artist", "title": "Title"}),
),
(
"Title",
{"disc": None, "track": None, "artist": None, "title": "Title"},
FilenameMatch({"title": "Title"}),
),
],
)
def test_parse_track_info(text, matchgroup):
f = FromFilenamePlugin()
m = f._parse_track_info(text)
assert matchgroup == m
assert dict(matchgroup.items()) == dict(m.items())
@pytest.mark.parametrize(
@ -117,134 +128,137 @@ def test_parse_track_info(text, matchgroup):
(
# highly unlikely
"",
{
"albumartist": None,
"album": None,
"year": None,
"catalognum": None,
"media": None,
},
FilenameMatch(
{
"albumartist": None,
"album": None,
"year": None,
"catalognum": None,
"media": None,
}
),
),
(
"1970",
{
"albumartist": None,
"album": None,
"year": "1970",
"catalognum": None,
"media": None,
},
FilenameMatch(
{
"year": "1970",
}
),
),
(
"Album Title",
{
"albumartist": None,
"album": "Album Title",
"year": None,
"catalognum": None,
"media": None,
},
FilenameMatch(
{
"album": "Album Title",
}
),
),
(
"Artist - Album Title",
{
"albumartist": "Artist",
"album": "Album Title",
"year": None,
"catalognum": None,
"media": None,
},
FilenameMatch(
{
"albumartist": "Artist",
"album": "Album Title",
}
),
),
(
"Artist - Album Title (2024)",
{
"albumartist": "Artist",
"album": "Album Title",
"year": "2024",
"catalognum": None,
"media": None,
},
FilenameMatch(
{
"albumartist": "Artist",
"album": "Album Title",
"year": "2024",
}
),
),
(
"Artist - 2024 - Album Title [flac]",
{
"albumartist": "Artist",
"album": "Album Title",
"year": "2024",
"catalognum": None,
"media": None,
},
FilenameMatch(
{
"albumartist": "Artist",
"album": "Album Title",
"year": "2024",
}
),
),
(
"(2024) Album Title [CATALOGNUM] WEB",
# sometimes things are just going to be unparsable
{
"albumartist": "Album Title",
"album": "WEB",
"year": "2024",
"catalognum": "CATALOGNUM",
"media": None,
},
FilenameMatch(
{
"albumartist": "Album Title",
"album": "WEB",
"year": "2024",
"catalognum": "CATALOGNUM",
}
),
),
(
"{2024} Album Artist - Album Title [INFO-WAV]",
{
"albumartist": "Album Artist",
"album": "Album Title",
"year": "2024",
"catalognum": None,
"media": None,
},
FilenameMatch(
{
"albumartist": "Album Artist",
"album": "Album Title",
"year": "2024",
}
),
),
(
"VA - Album Title [2025] [CD-FLAC]",
{
"albumartist": "Various Artists",
"album": "Album Title",
"year": "2025",
"catalognum": None,
"media": "CD",
},
FilenameMatch(
{
"albumartist": "Various Artists",
"album": "Album Title",
"year": "2025",
"media": "CD",
}
),
),
(
"Artist - Album Title 3000 (1998) [FLAC] {CATALOGNUM}",
{
"albumartist": "Artist",
"album": "Album Title 3000",
"year": "1998",
"catalognum": "CATALOGNUM",
"media": None,
},
FilenameMatch(
{
"albumartist": "Artist",
"album": "Album Title 3000",
"year": "1998",
"catalognum": "CATALOGNUM",
}
),
),
(
"various - cd album (2023) [catalognum 123] {vinyl mp3}",
{
"albumartist": "Various Artists",
"album": "cd album",
"year": "2023",
"catalognum": "catalognum 123",
"media": "Vinyl",
},
FilenameMatch(
{
"albumartist": "Various Artists",
"album": "cd album",
"year": "2023",
"catalognum": "catalognum 123",
"media": "Vinyl",
}
),
),
(
"[CATALOG567] Album - Various (2020) [WEB-FLAC]",
{
"albumartist": "Various Artists",
"album": "Album",
"year": "2020",
"catalognum": "CATALOG567",
"media": "Digital Media",
},
FilenameMatch(
{
"albumartist": "Various Artists",
"album": "Album",
"year": "2020",
"catalognum": "CATALOG567",
"media": "Digital Media",
}
),
),
(
"Album 3000 {web}",
{
"albumartist": None,
"album": "Album 3000",
"year": None,
"catalognum": None,
"media": "Digital Media",
},
FilenameMatch(
{
"album": "Album 3000",
"media": "Digital Media",
}
),
),
],
)
@ -253,17 +267,18 @@ def test_parse_album_info(text, matchgroup):
m = f._parse_album_info(text)
assert matchgroup == m
@pytest.mark.parametrize("string,pattern",[
@pytest.mark.parametrize(
"string,pattern",
[
(
"$albumartist - $album ($year) {$comments}",
r"(?P<albumartist>.+)\ \-\ (?P<album>.+)\ \((?P<year>.+)\)\ \ \{(?P<comments>.+)\}"
"$albumartist - $album ($year) {$comments}",
r"(?P<albumartist>.+)\ \-\ (?P<album>.+)\ \((?P<year>.+)\)\ \ \{(?P<comments>.+)\}",
),
(
"$",
None
),
])
def test_parse_user_pattern_strings(string,pattern):
("$", None),
],
)
def test_parse_user_pattern_strings(string, pattern):
f = FromFilenamePlugin()
assert f._parse_user_pattern_strings(string) == pattern
@ -555,8 +570,7 @@ class TestFromFilename(PluginMixin):
def test_singleton_import(self):
task = SingletonImportTask(
toppath=None,
item=mock_item(path="/01 Track.wav")
toppath=None, item=mock_item(path="/01 Track.wav")
)
f = FromFilenamePlugin()
f.filename_task(task, Session())
@ -630,39 +644,42 @@ class TestFromFilename(PluginMixin):
assert res.year == expected.year
assert res.title == expected.title
@pytest.mark.parametrize("patterns,expected", [
(
{
"folder": ["($comments) - {$albumartist} - {$album}"],
"file": ["$artist - $track - $title"]
},
mock_item(
path="/(Comment) - {Album Artist} - {Album}/Artist - 02 - Title.flac",
comments="Comment",
albumartist="Album Artist",
album="Album",
artist="Artist",
track=2,
title="Title",
)
),
(
{
"folder": ["[$comments] - {$albumartist} - {$album}"],
"file": ["$artist - $track - $title"]
},
mock_item(
path="/(Comment) - {Album Artist} - {Album}/Artist - 02 - Title.flac",
artist="Artist",
track=2,
title="Title",
catalognum="Comment"
)
)
])
@pytest.mark.parametrize(
"patterns,expected",
[
(
{
"folder": ["($comments) - {$albumartist} - {$album}"],
"file": ["$artist - $track - $title"],
},
mock_item(
path="/(Comment) - {Album Artist} - {Album}/Artist - 02 - Title.flac",
comments="Comment",
albumartist="Album Artist",
album="Album",
artist="Artist",
track=2,
title="Title",
),
),
(
{
"folder": ["[$comments] - {$albumartist} - {$album}"],
"file": ["$artist - $track - $title"],
},
mock_item(
path="/(Comment) - {Album Artist} - {Album}/Artist - 02 - Title.flac",
artist="Artist",
track=2,
title="Title",
catalognum="Comment",
),
),
],
)
def test_user_patterns(self, patterns, expected):
task = mock_task([mock_item(path=expected.path)])
with self.configure_plugin({ "patterns": patterns }):
with self.configure_plugin({"patterns": patterns}):
f = FromFilenamePlugin()
f.filename_task(task, Session())
res = task.items[0]
@ -675,3 +692,5 @@ class TestFromFilename(PluginMixin):
assert res.year == expected.year
assert res.title == expected.title
def test_escape(self):
assert FromFilenamePlugin._escape("{text}") == "{{text}}"