diff --git a/conftest.py b/conftest.py index c9d1a40..b21a96e 100644 --- a/conftest.py +++ b/conftest.py @@ -118,15 +118,15 @@ def seeded_cache(config: Config) -> None: , ('r3', '{dirpaths[2]}', null , '0000-01-01T00:00:00+00:00', '999' , 'Release 3', 'album' , 2021 , 1780 , 'DG-002' , 1 , true , '3'); INSERT INTO releases_genres - (release_id, genre , genre_sanitized, position) -VALUES ('r1' , 'Techno' , 'Techno' , 1) - , ('r1' , 'Deep House', 'Deep House' , 2) - , ('r2' , 'Classical' , 'Classical' , 1); + (release_id, genre , position) +VALUES ('r1' , 'Techno' , 1) + , ('r1' , 'Deep House', 2) + , ('r2' , 'Classical' , 1); INSERT INTO releases_labels - (release_id, label , label_sanitized, position) -VALUES ('r1' , 'Silk Music' , 'Silk Music' , 1) - , ('r2' , 'Native State', 'Native State' , 1); + (release_id, label , position) +VALUES ('r1' , 'Silk Music' , 1) + , ('r2' , 'Native State', 1); INSERT INTO tracks (id , source_path , source_mtime, title , release_id, tracknumber, tracktotal, discnumber, duration_seconds, metahash) @@ -136,20 +136,20 @@ def seeded_cache(config: Config) -> None: , ('t4', '{musicpaths[3]}', '999' , 'Track 1', 'r3' , '01' , 1 , '01' , 120 , '4'); INSERT INTO releases_artists - (release_id, artist , artist_sanitized , role , position) -VALUES ('r1' , 'Techno Man' , 'Techno Man' , 'main' , 1) - , ('r1' , 'Bass Man' , 'Bass Man' , 'main' , 2) - , ('r2' , 'Violin Woman' , 'Violin Woman' , 'main' , 1) - , ('r2' , 'Conductor Woman', 'Conductor Woman', 'guest', 2); + (release_id, artist , role , position) +VALUES ('r1' , 'Techno Man' , 'main' , 1) + , ('r1' , 'Bass Man' , 'main' , 2) + , ('r2' , 'Violin Woman' , 'main' , 1) + , ('r2' , 'Conductor Woman', 'guest', 2); INSERT INTO tracks_artists - (track_id, artist , artist_sanitized , role , position) -VALUES ('t1' , 'Techno Man' , 'Techno Man' , 'main' , 1) - , ('t1' , 'Bass Man' , 'Bass Man' , 'main' , 2) - , ('t2' , 'Techno Man' , 'Techno Man' , 'main' , 1) - , ('t2' , 'Bass Man' , 'Bass Man' , 'main' , 2) - , ('t3' , 'Violin Woman' , 'Violin Woman' , 'main' , 1) - , ('t3' , 'Conductor Woman', 'Conductor Woman', 'guest', 2); + (track_id, artist , role , position) +VALUES ('t1' , 'Techno Man' , 'main' , 1) + , ('t1' , 'Bass Man' , 'main' , 2) + , ('t2' , 'Techno Man' , 'main' , 1) + , ('t2' , 'Bass Man' , 'main' , 2) + , ('t3' , 'Violin Woman' , 'main' , 1) + , ('t3' , 'Conductor Woman', 'guest', 2); INSERT INTO collages (name , source_mtime) diff --git a/rose/cache.py b/rose/cache.py index 92eb59d..e08d01c 100644 --- a/rose/cache.py +++ b/rose/cache.py @@ -990,19 +990,13 @@ def _update_cache_for_releases_executor( ) upd_release_ids.append(release.id) for pos, genre in enumerate(release.genres): - upd_release_genre_args.append( - [release.id, genre, sanitize_dirname(genre, False), pos] - ) + upd_release_genre_args.append([release.id, genre, pos]) for pos, label in enumerate(release.labels): - upd_release_label_args.append( - [release.id, label, sanitize_dirname(label, False), pos] - ) + upd_release_label_args.append([release.id, label, pos]) pos = 0 for role, artists in release.releaseartists.items(): for art in artists: - upd_release_artist_args.append( - [release.id, art.name, sanitize_dirname(art.name, False), role, pos] - ) + upd_release_artist_args.append([release.id, art.name, role, pos]) pos += 1 if track_ids_to_insert: @@ -1028,9 +1022,7 @@ def _update_cache_for_releases_executor( pos = 0 for role, artists in track.trackartists.items(): for art in artists: - upd_track_artist_args.append( - [track.id, art.name, sanitize_dirname(art.name, False), role, pos] - ) + upd_track_artist_args.append([track.id, art.name, role, pos]) pos += 1 logger.debug(f"Release update scheduling loop time {time.time() - loop_start=}") @@ -1083,8 +1075,8 @@ def _update_cache_for_releases_executor( ) conn.execute( f""" - INSERT INTO releases_genres (release_id, genre, genre_sanitized, position) - VALUES {",".join(["(?,?,?,?)"]*len(upd_release_genre_args))} + INSERT INTO releases_genres (release_id, genre, position) + VALUES {",".join(["(?,?,?)"]*len(upd_release_genre_args))} """, _flatten(upd_release_genre_args), ) @@ -1098,8 +1090,8 @@ def _update_cache_for_releases_executor( ) conn.execute( f""" - INSERT INTO releases_labels (release_id, label, label_sanitized, position) - VALUES {",".join(["(?,?,?,?)"]*len(upd_release_label_args))} + INSERT INTO releases_labels (release_id, label, position) + VALUES {",".join(["(?,?,?)"]*len(upd_release_label_args))} """, _flatten(upd_release_label_args), ) @@ -1113,8 +1105,8 @@ def _update_cache_for_releases_executor( ) conn.execute( f""" - INSERT INTO releases_artists (release_id, artist, artist_sanitized, role, position) - VALUES {",".join(["(?,?,?,?,?)"]*len(upd_release_artist_args))} + INSERT INTO releases_artists (release_id, artist, role, position) + VALUES {",".join(["(?,?,?,?)"]*len(upd_release_artist_args))} """, _flatten(upd_release_artist_args), ) @@ -1147,8 +1139,8 @@ def _update_cache_for_releases_executor( ) conn.execute( f""" - INSERT INTO tracks_artists (track_id, artist, artist_sanitized, role, position) - VALUES {",".join(["(?,?,?,?,?)"]*len(upd_track_artist_args))} + INSERT INTO tracks_artists (track_id, artist, role, position) + VALUES {",".join(["(?,?,?,?)"]*len(upd_track_artist_args))} """, _flatten(upd_track_artist_args), ) @@ -1689,42 +1681,41 @@ def update_cache_evict_nonexistent_playlists(c: Config) -> None: def list_releases_delete_this( c: Config, - sanitized_artist_filter: str | None = None, - sanitized_genre_filter: str | None = None, - sanitized_label_filter: str | None = None, + artist_filter: str | None = None, + genre_filter: str | None = None, + label_filter: str | None = None, new: bool | None = None, ) -> list[CachedRelease]: with connect(c) as conn: query = "SELECT * FROM releases_view WHERE 1=1" args: list[str | bool] = [] - if sanitized_artist_filter: - sanitized_artists: list[str] = [sanitized_artist_filter] - for alias in c.sanitized_artist_aliases_map.get(sanitized_artist_filter, []): - sanitized_artists.append(alias) + if artist_filter: + artists: list[str] = [artist_filter] + for alias in c.artist_aliases_map.get(artist_filter, []): + artists.append(alias) query += f""" AND EXISTS ( SELECT * FROM releases_artists - WHERE release_id = id AND artist_sanitized IN ({','.join(['?']*len(sanitized_artists))}) + WHERE release_id = id AND artist IN ({','.join(['?']*len(artists))}) ) """ - args.extend(sanitized_artists) - if sanitized_genre_filter: - # TODO(NOW): Umm.. sanitized to not sanitized? + args.extend(artists) + if genre_filter: query += """ AND EXISTS ( SELECT * FROM releases_genres - WHERE release_id = id AND genre_sanitized = ? + WHERE release_id = id AND genre = ? ) """ - args.append(sanitized_genre_filter) - if sanitized_label_filter: + args.append(genre_filter) + if label_filter: query += """ AND EXISTS ( SELECT * FROM releases_labels - WHERE release_id = id AND label_sanitized = ? + WHERE release_id = id AND label = ? ) """ - args.append(sanitized_label_filter) + args.append(label_filter) if new is not None: query += " AND new = ?" args.append(new) @@ -2100,22 +2091,22 @@ def collage_exists(c: Config, collage_name: str) -> bool: return bool(cursor.fetchone()[0]) -def list_artists(c: Config) -> list[tuple[str, str]]: +def list_artists(c: Config) -> list[str]: with connect(c) as conn: - cursor = conn.execute("SELECT DISTINCT artist, artist_sanitized FROM releases_artists") - return [(row["artist"], row["artist_sanitized"]) for row in cursor] + cursor = conn.execute("SELECT DISTINCT artist FROM releases_artists") + return [row["artist"] for row in cursor] -def artist_exists(c: Config, artist_sanitized: str) -> bool: - args: list[str] = [artist_sanitized] - for alias in c.sanitized_artist_aliases_map.get(artist_sanitized, []): +def artist_exists(c: Config, artist: str) -> bool: + args: list[str] = [artist] + for alias in c.artist_aliases_map.get(artist, []): args.append(alias) with connect(c) as conn: cursor = conn.execute( f""" SELECT EXISTS( SELECT * FROM releases_artists - WHERE artist_sanitized IN ({','.join(['?']*len(args))}) + WHERE artist IN ({','.join(['?']*len(args))}) ) """, args, @@ -2123,32 +2114,32 @@ def artist_exists(c: Config, artist_sanitized: str) -> bool: return bool(cursor.fetchone()[0]) -def list_genres(c: Config) -> list[tuple[str, str]]: +def list_genres(c: Config) -> list[str]: with connect(c) as conn: - cursor = conn.execute("SELECT DISTINCT genre, genre_sanitized FROM releases_genres") - return [(row["genre"], row["genre_sanitized"]) for row in cursor] + cursor = conn.execute("SELECT DISTINCT genre FROM releases_genres") + return [row["genre"] for row in cursor] -def genre_exists(c: Config, genre_sanitized: str) -> bool: +def genre_exists(c: Config, genre: str) -> bool: with connect(c) as conn: cursor = conn.execute( - "SELECT EXISTS(SELECT * FROM releases_genres WHERE genre_sanitized = ?)", - (genre_sanitized,), + "SELECT EXISTS(SELECT * FROM releases_genres WHERE genre = ?)", + (genre,), ) return bool(cursor.fetchone()[0]) -def list_labels(c: Config) -> list[tuple[str, str]]: +def list_labels(c: Config) -> list[str]: with connect(c) as conn: - cursor = conn.execute("SELECT DISTINCT label, label_sanitized FROM releases_labels") - return [(row["label"], row["label_sanitized"]) for row in cursor] + cursor = conn.execute("SELECT DISTINCT label, label FROM releases_labels") + return [row["label"] for row in cursor] -def label_exists(c: Config, label_sanitized: str) -> bool: +def label_exists(c: Config, label: str) -> bool: with connect(c) as conn: cursor = conn.execute( - "SELECT EXISTS(SELECT * FROM releases_labels WHERE label_sanitized = ?)", - (label_sanitized,), + "SELECT EXISTS(SELECT * FROM releases_labels WHERE label = ?)", + (label,), ) return bool(cursor.fetchone()[0]) diff --git a/rose/cache.sql b/rose/cache.sql index 976767b..2384066 100644 --- a/rose/cache.sql +++ b/rose/cache.sql @@ -29,26 +29,22 @@ CREATE INDEX releases_new ON releases(new); CREATE TABLE releases_genres ( release_id TEXT REFERENCES releases(id) ON DELETE CASCADE, genre TEXT, - genre_sanitized TEXT NOT NULL, position INTEGER NOT NULL, PRIMARY KEY (release_id, genre), UNIQUE (release_id, position) ); CREATE INDEX releases_genres_release_id_position ON releases_genres(release_id, position); CREATE INDEX releases_genres_genre ON releases_genres(genre); -CREATE INDEX releases_genres_genre_sanitized ON releases_genres(genre_sanitized); CREATE TABLE releases_labels ( release_id TEXT REFERENCES releases(id) ON DELETE CASCADE, label TEXT, - label_sanitized TEXT NOT NULL, position INTEGER NOT NULL, PRIMARY KEY (release_id, label), UNIQUE (release_id, position) ); CREATE INDEX releases_labels_release_id_position ON releases_labels(release_id, position); CREATE INDEX releases_labels_label ON releases_labels(label); -CREATE INDEX releases_labels_label_sanitized ON releases_labels(label_sanitized); CREATE TABLE tracks ( id TEXT PRIMARY KEY, @@ -85,7 +81,6 @@ INSERT INTO artist_role_enum (value) VALUES CREATE TABLE releases_artists ( release_id TEXT REFERENCES releases(id) ON DELETE CASCADE, artist TEXT, - artist_sanitized TEXT NOT NULL, role TEXT REFERENCES artist_role_enum(value) NOT NULL, position INTEGER NOT NULL, PRIMARY KEY (release_id, artist, role) @@ -93,12 +88,10 @@ CREATE TABLE releases_artists ( ); CREATE INDEX releases_artists_release_id_position ON releases_artists(release_id, position); CREATE INDEX releases_artists_artist ON releases_artists(artist); -CREATE INDEX releases_artists_artist_sanitized ON releases_artists(artist_sanitized); CREATE TABLE tracks_artists ( track_id TEXT REFERENCES tracks(id) ON DELETE CASCADE, artist TEXT, - artist_sanitized TEXT NOT NULL, role TEXT REFERENCES artist_role_enum(value) NOT NULL, position INTEGER NOT NULL, PRIMARY KEY (track_id, artist, role), @@ -106,7 +99,6 @@ CREATE TABLE tracks_artists ( ); CREATE INDEX tracks_artists_track_id_position ON tracks_artists(track_id, position); CREATE INDEX tracks_artists_artist ON tracks_artists(artist); -CREATE INDEX tracks_artists_artist_sanitized ON tracks_artists(artist_sanitized); CREATE TABLE collages ( name TEXT PRIMARY KEY, diff --git a/rose/cache_test.py b/rose/cache_test.py index 1bcda3e..a156f0c 100644 --- a/rose/cache_test.py +++ b/rose/cache_test.py @@ -458,26 +458,26 @@ def test_update_cache_releases_evicts_relations(config: Config) -> None: with connect(config) as conn: conn.execute( """ - INSERT INTO releases_genres (release_id, genre, genre_sanitized, position) - VALUES ('ilovecarly', 'lalala', 'lalala', 2) + INSERT INTO releases_genres (release_id, genre, position) + VALUES ('ilovecarly', 'lalala', 2) """, ) conn.execute( """ - INSERT INTO releases_labels (release_id, label, label_sanitized, position) - VALUES ('ilovecarly', 'lalala', 'lalala', 1) + INSERT INTO releases_labels (release_id, label, position) + VALUES ('ilovecarly', 'lalala', 1) """, ) conn.execute( """ - INSERT INTO releases_artists (release_id, artist, artist_sanitized, role, position) - VALUES ('ilovecarly', 'lalala', 'lalala', 'main', 1) + INSERT INTO releases_artists (release_id, artist, role, position) + VALUES ('ilovecarly', 'lalala', 'main', 1) """, ) conn.execute( """ - INSERT INTO tracks_artists (track_id, artist, artist_sanitized, role, position) - SELECT id, 'lalala', 'lalala', 'main', 1 FROM tracks + INSERT INTO tracks_artists (track_id, artist, role, position) + SELECT id, 'lalala', 'main', 1 FROM tracks """, ) # Second cache refresh. @@ -1410,10 +1410,10 @@ def test_get_track_logtext(config: Config) -> None: def test_list_artists(config: Config) -> None: artists = list_artists(config) assert set(artists) == { - ("Techno Man", "Techno Man"), - ("Bass Man", "Bass Man"), - ("Violin Woman", "Violin Woman"), - ("Conductor Woman", "Conductor Woman"), + "Techno Man", + "Bass Man", + "Violin Woman", + "Conductor Woman", } @@ -1421,16 +1421,16 @@ def test_list_artists(config: Config) -> None: def test_list_genres(config: Config) -> None: genres = list_genres(config) assert set(genres) == { - ("Techno", "Techno"), - ("Deep House", "Deep House"), - ("Classical", "Classical"), + "Techno", + "Deep House", + "Classical", } @pytest.mark.usefixtures("seeded_cache") def test_list_labels(config: Config) -> None: labels = list_labels(config) - assert set(labels) == {("Silk Music", "Silk Music"), ("Native State", "Native State")} + assert set(labels) == {"Silk Music", "Native State"} @pytest.mark.usefixtures("seeded_cache") diff --git a/rose/config.py b/rose/config.py index 72e5806..1761885 100644 --- a/rose/config.py +++ b/rose/config.py @@ -20,7 +20,7 @@ import appdirs import tomllib -from rose.common import RoseExpectedError, sanitize_dirname +from rose.common import RoseExpectedError from rose.rule_parser import MetadataRule, RuleSyntaxError from rose.templates import ( DEFAULT_TEMPLATE_PAIR, @@ -484,11 +484,3 @@ def cache_database_path(self) -> Path: @functools.cached_property def watchdog_pid_path(self) -> Path: return self.cache_dir / "watchdog.pid" - - @functools.cached_property - def sanitized_artist_aliases_map(self) -> dict[str, list[str]]: - return {sanitize_dirname(k, False): v for k, v in self.artist_aliases_map.items()} - - @functools.cached_property - def sanitized_artist_aliases_parents_map(self) -> dict[str, list[str]]: - return {sanitize_dirname(k, False): v for k, v in self.artist_aliases_parents_map.items()} diff --git a/rose/virtualfs.py b/rose/virtualfs.py index 8f0beff..8d52de6 100644 --- a/rose/virtualfs.py +++ b/rose/virtualfs.py @@ -3,7 +3,7 @@ Object-Oriented style, against my typical sensibilities, because that's how the FUSE libraries tend to be implemented. But it's OK :) -Since this is a pretty hefty module, we'll cover the organization. This module contains 8 classes: +Since this is a pretty hefty module, we'll cover the organization. This module contains 9 classes: 1. TTLCache: A wrapper around dict that expires key/value pairs after a given TTL. @@ -13,20 +13,23 @@ 3. VirtualNameGenerator: A class that generates virtual directory and filenames given releases and tracks, and maintains inverse mappings for resolving release IDs from virtual paths. -4. "CanShow"er: An abstraction that encapsulates the logic of whether an artist, genre, or label +4. Sanitizer: A class that sanitizes artist/genre/label names and maintains a mapping of + sanitized->unsanitized for core library calls. + +5. "CanShow"er: An abstraction that encapsulates the logic of whether an artist, genre, or label should be shown in their respective virtual views, based on the whitelist/blacklist configuration parameters. -5. FileHandleGenerator: A class that keeps generates new file handles. It is a counter that wraps +6. FileHandleGenerator: A class that keeps generates new file handles. It is a counter that wraps back to 5 when the file handles exceed ~10k, as to avoid any overflows. -6. RoseLogicalCore: A logical representation of Rose's filesystem logic, freed from the annoying +7. RoseLogicalCore: A logical representation of Rose's filesystem logic, freed from the annoying implementation details that a low-level library like `llfuse` comes with. -7. INodeMapper: A class that tracks the INode <-> Path mappings. It is used to convert inodes to +8. INodeMapper: A class that tracks the INode <-> Path mappings. It is used to convert inodes to paths in VirtualFS. -8. VirtualFS: The main Virtual Filesystem class, which manages the annoying implementation details +9. VirtualFS: The main Virtual Filesystem class, which manages the annoying implementation details of a low-level virtual filesystem, and delegates logic to the above classes. It uses INodeMapper and VirtualPath to translate inodes into semantically useful dataclasses, and then passes them into RoseLogicalCore. @@ -209,6 +212,21 @@ def track_parent(self) -> VirtualPath: release=self.release, ) + @property + def artist_parent(self) -> VirtualPath: + """Parent path of an artist: Used as an input to the Sanitizer.""" + return VirtualPath(view=self.view) + + @property + def genre_parent(self) -> VirtualPath: + """Parent path of a genre: Used as an input to the Sanitizer.""" + return VirtualPath(view=self.view) + + @property + def label_parent(self) -> VirtualPath: + """Parent path of a label: Used as an input to the Sanitizer.""" + return VirtualPath(view=self.view) + @classmethod def parse(cls, path: Path) -> VirtualPath: parts = str(path.resolve()).split("/")[1:] # First part is always empty string. @@ -540,6 +558,48 @@ def lookup_track(self, p: VirtualPath) -> str | None: return None +class Sanitizer: + """ + Sanitizes artist/genre/label names and maintains a mapping of sanitized->unsanitized for core + library calls. + """ + + def __init__(self, rose: RoseLogicalCore) -> None: + self._rose = rose + self._to_sanitized: dict[str, str] = {} + self._to_unsanitized: dict[str, str] = {} + + def sanitize(self, unsanitized: str) -> str: + try: + return self._to_sanitized[unsanitized] + except KeyError: + sanitized = sanitize_dirname(unsanitized, enforce_maxlen=True) + self._to_sanitized[unsanitized] = sanitized + self._to_unsanitized[sanitized] = unsanitized + return sanitized + + def unsanitize(self, sanitized: str, parent: VirtualPath) -> str: + with contextlib.suppress(KeyError): + return self._to_unsanitized[sanitized] + + # This should never happen for a valid path. + logger.debug( + f"SANITIZER: Failed to find corresponding unsanitized string for '{sanitized}'." + ) + logger.debug( + f"SANITIZER: Invoking readdir before retrying unsanitized resolution on {sanitized}" + ) + # Performant way to consume an iterator completely. + collections.deque(self._rose.readdir(parent), maxlen=0) + logger.debug( + f"SANITIZER: Finished readdir call: retrying file virtual name resolution on {sanitized}" + ) + try: + return self._to_unsanitized[sanitized] + except KeyError as e: + raise llfuse.FUSEError(errno.ENOENT) from e + + class CanShower: """ I'm great at naming things. This is "can show"-er, determining whether we can show an @@ -637,6 +697,7 @@ def __init__(self, config: Config, fhandler: FileHandleManager): self.config = config self.fhandler = fhandler self.vnames = VirtualNameGenerator(config) + self.sanitizer = Sanitizer(self) self.can_show = CanShower(config) # This map stores the state for "file creation" operations. We currently have two file # creation operations: @@ -776,7 +837,9 @@ def getattr(self, p: VirtualPath) -> dict[str, Any]: # 6. Labels if p.label: - if not label_exists(self.config, p.label) or not self.can_show.label(p.label): + if not label_exists( + self.config, self.sanitizer.unsanitize(p.label, p.label_parent) + ) or not self.can_show.label(self.sanitizer.unsanitize(p.label, p.label_parent)): raise llfuse.FUSEError(errno.ENOENT) if p.release: return self._getattr_release(p) @@ -784,7 +847,9 @@ def getattr(self, p: VirtualPath) -> dict[str, Any]: # 5. Genres if p.genre: - if not genre_exists(self.config, p.genre) or not self.can_show.genre(p.genre): + if not genre_exists( + self.config, self.sanitizer.unsanitize(p.genre, p.genre_parent) + ) or not self.can_show.genre(self.sanitizer.unsanitize(p.genre, p.genre_parent)): raise llfuse.FUSEError(errno.ENOENT) if p.release: return self._getattr_release(p) @@ -792,7 +857,9 @@ def getattr(self, p: VirtualPath) -> dict[str, Any]: # 4. Artists if p.artist: - if not artist_exists(self.config, p.artist) or not self.can_show.artist(p.artist): + if not artist_exists( + self.config, self.sanitizer.unsanitize(p.artist, p.artist_parent) + ) or not self.can_show.artist(self.sanitizer.unsanitize(p.artist, p.artist_parent)): raise llfuse.FUSEError(errno.ENOENT) if p.release: return self._getattr_release(p) @@ -850,36 +917,38 @@ def readdir(self, p: VirtualPath) -> Iterator[tuple[str, dict[str, Any]]]: raise llfuse.FUSEError(errno.ENOENT) if p.artist or p.genre or p.label or p.view in ["Releases", "New", "Recently Added"]: + # fmt: off releases = list_releases_delete_this( self.config, - sanitized_artist_filter=p.artist, - sanitized_genre_filter=p.genre, - sanitized_label_filter=p.label, + artist_filter=self.sanitizer.unsanitize(p.artist, p.artist_parent) if p.artist else None, + genre_filter=self.sanitizer.unsanitize(p.genre, p.artist_parent) if p.genre else None, + label_filter=self.sanitizer.unsanitize(p.label, p.artist_parent) if p.label else None, new=True if p.view == "New" else None, ) + # fmt: on for rls, vname in self.vnames.list_release_paths(p, releases): yield vname, self.stat("dir", rls.source_path) return if p.view == "Artists": - for artist, sanitized_artist in list_artists(self.config): + for artist in list_artists(self.config): if not self.can_show.artist(artist): continue - yield sanitized_artist, self.stat("dir") + yield self.sanitizer.sanitize(artist), self.stat("dir") return if p.view == "Genres": - for genre, sanitized_genre in list_genres(self.config): + for genre in list_genres(self.config): if not self.can_show.genre(genre): continue - yield sanitized_genre, self.stat("dir") + yield self.sanitizer.sanitize(genre), self.stat("dir") return if p.view == "Labels": - for label, sanitized_label in list_labels(self.config): + for label in list_labels(self.config): if not self.can_show.label(label): continue - yield sanitized_label, self.stat("dir") + yield self.sanitizer.sanitize(label), self.stat("dir") return if p.view == "Collages" and p.collage: