diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index 34ea1155a..c4bfd538a 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -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 diff --git a/test/plugins/test_fromfilename.py b/test/plugins/test_fromfilename.py index 98709c201..c00b56721 100644 --- a/test/plugins/test_fromfilename.py +++ b/test/plugins/test_fromfilename.py @@ -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.+)\ \-\ (?P.+)\ \((?P.+)\)\ \ \{(?P.+)\}" + "$albumartist - $album ($year) {$comments}", + r"(?P.+)\ \-\ (?P.+)\ \((?P.+)\)\ \ \{(?P.+)\}", ), - ( - "$", - 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}}"