From edee6fc03ae074b714223c0ba7be3d66227dca7f Mon Sep 17 00:00:00 2001 From: blissful Date: Thu, 2 May 2024 16:33:50 -0400 Subject: [PATCH] add option to hide labels/genres/artists that solely belong to new releases --- conftest.py | 7 ++-- docs/CONFIGURATION.md | 8 +++++ rose/__init__.py | 8 ++++- rose/cache.py | 73 +++++++++++++++++++++++++++++++------- rose/cache_test.py | 40 +++++++++++++-------- rose/collages_test.py | 4 +-- rose/config.py | 47 ++++++++++++++++++++++++ rose/config_test.py | 36 +++++++++++++++++++ rose/playlists_test.py | 4 +-- rose/releases_test.py | 6 ++-- rose/tracks_test.py | 4 +-- rose_vfs/virtualfs.py | 24 ++++++++----- rose_vfs/virtualfs_test.py | 30 ++++++++++++---- 13 files changed, 236 insertions(+), 55 deletions(-) diff --git a/conftest.py b/conftest.py index 4e5b473..93401dd 100644 --- a/conftest.py +++ b/conftest.py @@ -83,6 +83,9 @@ def config(isolated_dir: Path) -> Config: fuse_genres_blacklist=None, fuse_descriptors_blacklist=None, fuse_labels_blacklist=None, + hide_genres_with_only_new_releases=False, + hide_descriptors_with_only_new_releases=False, + hide_labels_with_only_new_releases=False, cover_art_stems=["cover", "folder", "art", "front"], valid_art_exts=["jpg", "jpeg", "png"], max_filename_bytes=180, @@ -117,8 +120,8 @@ def seeded_cache(config: Config) -> None: INSERT INTO releases (id , source_path , cover_image_path , added_at , datafile_mtime, title , releasetype, releasedate , originaldate, compositiondate, catalognumber, edition , disctotal, new , metahash) VALUES ('r1', '{dirpaths[0]}', null , '0000-01-01T00:00:00+00:00', '999' , 'Release 1', 'album' , '2023' , null , null , null , null , 1 , false, '1') - , ('r2', '{dirpaths[1]}', '{imagepaths[0]}', '0000-01-01T00:00:00+00:00', '999' , 'Release 2', 'album' , '2021' , '2019' , null , 'DG-001' , 'Deluxe', 1 , false, '2') - , ('r3', '{dirpaths[2]}', null , '0000-01-01T00:00:00+00:00', '999' , 'Release 3', 'album' , '2021-04-20', null , '1780' , 'DG-002' , null , 1 , true , '3'); + , ('r2', '{dirpaths[1]}', '{imagepaths[0]}', '0000-01-01T00:00:00+00:00', '999' , 'Release 2', 'album' , '2021' , '2019' , null , 'DG-001' , 'Deluxe', 1 , true , '2') + , ('r3', '{dirpaths[2]}', null , '0000-01-01T00:00:00+00:00', '999' , 'Release 3', 'album' , '2021-04-20', null , '1780' , 'DG-002' , null , 1 , false, '3'); INSERT INTO releases_genres (release_id, genre , position) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 44626bb..77d1381 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -82,6 +82,14 @@ fuse_genres_blacklist = [ "xxx" ] fuse_descriptors_blacklist = [ "xxx" ] fuse_labels_blacklist = [ "xxx" ] +# Whether to hide the genres, descriptors, and labels from new releases from +# being returned in when listing genres/descriptors/labels. This is useful new +# releases are improperly tagged, as those tags tend to be very incorrect by +# default. +hide_genres_with_only_new_releases = true +hide_descriptors_with_only_new_releases = true +hide_labels_with_only_new_releases = true + # When Rosé scans a release directory, it looks for cover art that matches: # # 1. A supported file "stem" (the filename excluding the extension). diff --git a/rose/__init__.py b/rose/__init__.py index 48d9d16..720b1aa 100644 --- a/rose/__init__.py +++ b/rose/__init__.py @@ -20,6 +20,9 @@ calculate_track_logtext, collage_exists, descriptor_exists, + DescriptorEntry, + LabelEntry, + GenreEntry, genre_exists, get_collage, get_path_of_track_in_playlist, @@ -98,9 +101,13 @@ "CachedRelease", "CachedTrack", "Config", + "DescriptorEntry", + "GenreEntry", + "LabelEntry", "MetadataAction", "MetadataMatcher", "MetadataRule", + "PathContext", "PathTemplate", "RoseError", "RoseExpectedError", @@ -125,7 +132,6 @@ "descriptor_exists", "dump_all_collages", "dump_all_playlists", - "PathContext", "dump_all_releases", "dump_all_tracks", "dump_collage", diff --git a/rose/cache.py b/rose/cache.py index 161c965..b43241a 100644 --- a/rose/cache.py +++ b/rose/cache.py @@ -2273,15 +2273,27 @@ def artist_exists(c: Config, artist: str) -> bool: return bool(cursor.fetchone()[0]) -def list_genres(c: Config) -> list[str]: +@dataclass(frozen=True) +class GenreEntry: + genre: str + only_new_releases: bool + + +def list_genres(c: Config) -> list[GenreEntry]: with connect(c) as conn: - cursor = conn.execute("SELECT DISTINCT genre FROM releases_genres") - rval = set() + query = """ + SELECT rg.genre, MIN(r.id) AS has_non_new_release + FROM releases_genres rg + LEFT JOIN releases r ON r.id = rg.release_id AND NOT r.new + GROUP BY rg.genre + """ + cursor = conn.execute(query) + rval: dict[str, bool] = {} for row in cursor: - genre = row["genre"] - rval.add(genre) - rval.update(TRANSIENT_PARENT_GENRES.get(genre, [])) - return list(rval) + rval[row["genre"]] = row["has_non_new_release"] is None + for g in TRANSIENT_PARENT_GENRES.get(row["genre"], []): + rval[g] = rval.get(g, False) or row["has_non_new_release"] is None + return [GenreEntry(genre=k, only_new_releases=v) for k, v in rval.items()] def genre_exists(c: Config, genre: str) -> bool: @@ -2295,10 +2307,29 @@ def genre_exists(c: Config, genre: str) -> bool: return bool(cursor.fetchone()[0]) -def list_descriptors(c: Config) -> list[str]: +@dataclass(frozen=True) +class DescriptorEntry: + descriptor: str + only_new_releases: bool + + +def list_descriptors(c: Config) -> list[DescriptorEntry]: with connect(c) as conn: - cursor = conn.execute("SELECT DISTINCT descriptor FROM releases_descriptors") - return [row["descriptor"] for row in cursor] + cursor = conn.execute( + """ + SELECT rg.descriptor, MIN(r.id) AS has_non_new_release + FROM releases_descriptors rg + LEFT JOIN releases r ON r.id = rg.release_id AND NOT r.new + GROUP BY rg.descriptor + """ + ) + return [ + DescriptorEntry( + descriptor=row["descriptor"], + only_new_releases=row["has_non_new_release"] is None, + ) + for row in cursor + ] def descriptor_exists(c: Config, descriptor: str) -> bool: @@ -2310,10 +2341,26 @@ def descriptor_exists(c: Config, descriptor: str) -> bool: return bool(cursor.fetchone()[0]) -def list_labels(c: Config) -> list[str]: +@dataclass(frozen=True) +class LabelEntry: + label: str + only_new_releases: bool + + +def list_labels(c: Config) -> list[LabelEntry]: with connect(c) as conn: - cursor = conn.execute("SELECT DISTINCT label FROM releases_labels") - return [row["label"] for row in cursor] + cursor = conn.execute( + """ + SELECT rg.label, MIN(r.id) AS has_non_new_release + FROM releases_labels rg + LEFT JOIN releases r ON r.id = rg.release_id AND NOT r.new + GROUP BY rg.label + """ + ) + return [ + LabelEntry(label=row["label"], only_new_releases=row["has_non_new_release"] is None) + for row in cursor + ] def label_exists(c: Config, label: str) -> bool: diff --git a/rose/cache_test.py b/rose/cache_test.py index 5ba3af9..e1f6322 100644 --- a/rose/cache_test.py +++ b/rose/cache_test.py @@ -16,6 +16,9 @@ CachedPlaylist, CachedRelease, CachedTrack, + DescriptorEntry, + GenreEntry, + LabelEntry, _unpack, artist_exists, collage_exists, @@ -1151,7 +1154,7 @@ def test_list_releases(config: Config) -> None: compositiondate=None, catalognumber="DG-001", disctotal=1, - new=False, + new=True, genres=["Classical"], parent_genres=[], labels=["Native State"], @@ -1180,7 +1183,7 @@ def test_list_releases(config: Config) -> None: compositiondate=RoseDate(1780), catalognumber="DG-002", disctotal=1, - new=True, + new=False, genres=[], parent_genres=[], labels=[], @@ -1426,7 +1429,7 @@ def test_list_tracks(config: Config) -> None: releasedate=RoseDate(2021), compositiondate=None, catalognumber="DG-001", - new=False, + new=True, disctotal=1, genres=["Classical"], parent_genres=[], @@ -1467,7 +1470,7 @@ def test_list_tracks(config: Config) -> None: releasedate=RoseDate(2021, 4, 20), compositiondate=RoseDate(1780), catalognumber="DG-002", - new=True, + new=False, disctotal=1, genres=[], parent_genres=[], @@ -1580,26 +1583,33 @@ def test_list_artists(config: Config) -> None: def test_list_genres(config: Config) -> None: genres = list_genres(config) assert set(genres) == { - "Techno", - "Deep House", - "Classical", - "Dance", - "Electronic", - "Electronic Dance Music", - "House", + GenreEntry("Techno", False), + GenreEntry("Deep House", False), + GenreEntry("Classical", True), + GenreEntry("Dance", False), + GenreEntry("Electronic", False), + GenreEntry("Electronic Dance Music", False), + GenreEntry("House", False), } @pytest.mark.usefixtures("seeded_cache") def test_list_descriptors(config: Config) -> None: descriptors = list_descriptors(config) - assert set(descriptors) == {"Warm", "Hot", "Wet"} + assert set(descriptors) == { + DescriptorEntry("Warm", False), + DescriptorEntry("Hot", False), + DescriptorEntry("Wet", True), + } @pytest.mark.usefixtures("seeded_cache") def test_list_labels(config: Config) -> None: labels = list_labels(config) - assert set(labels) == {"Silk Music", "Native State"} + assert set(labels) == { + LabelEntry("Silk Music", False), + LabelEntry("Native State", True), + } @pytest.mark.usefixtures("seeded_cache") @@ -1665,7 +1675,7 @@ def test_get_collage(config: Config) -> None: releasedate=RoseDate(2021), compositiondate=None, catalognumber="DG-001", - new=False, + new=True, disctotal=1, genres=["Classical"], parent_genres=[], @@ -1791,7 +1801,7 @@ def test_get_playlist(config: Config) -> None: releasedate=RoseDate(2021), compositiondate=None, catalognumber="DG-001", - new=False, + new=True, disctotal=1, genres=["Classical"], parent_genres=[], diff --git a/rose/collages_test.py b/rose/collages_test.py index 267a290..823db35 100644 --- a/rose/collages_test.py +++ b/rose/collages_test.py @@ -180,7 +180,7 @@ def test_dump_collage(config: Config) -> None: "releasedate": "2021", "compositiondate": None, "catalognumber": "DG-001", - "new": False, + "new": True, "disctotal": 1, "genres": ["Classical"], "parent_genres": [], @@ -270,7 +270,7 @@ def test_dump_collages(config: Config) -> None: "releasedate": "2021", "compositiondate": None, "catalognumber": "DG-001", - "new": False, + "new": True, "disctotal": 1, "genres": ["Classical"], "parent_genres": [], diff --git a/rose/config.py b/rose/config.py index 1c2e6b7..5dc2dd1 100644 --- a/rose/config.py +++ b/rose/config.py @@ -78,6 +78,10 @@ class Config: fuse_descriptors_blacklist: list[str] | None fuse_labels_blacklist: list[str] | None + hide_genres_with_only_new_releases: bool + hide_descriptors_with_only_new_releases: bool + hide_labels_with_only_new_releases: bool + cover_art_stems: list[str] valid_art_exts: list[str] @@ -307,6 +311,46 @@ def parse(cls, config_path_override: Path | None = None) -> Config: f"Cannot specify both fuse_labels_whitelist and fuse_labels_blacklist in configuration file ({cfgpath}): must specify only one or the other" ) + try: + hide_genres_with_only_new_releases = data["hide_genres_with_only_new_releases"] + del data["hide_genres_with_only_new_releases"] + if not isinstance(hide_genres_with_only_new_releases, bool): + raise ValueError(f"Must be a bool: got {type(hide_genres_with_only_new_releases)}") + except KeyError: + hide_genres_with_only_new_releases = False + except ValueError as e: + raise InvalidConfigValueError( + f"Invalid value for hide_genres_with_only_new_releases in configuration file ({cfgpath}): {e}" + ) from e + + try: + hide_descriptors_with_only_new_releases = data[ + "hide_descriptors_with_only_new_releases" + ] + del data["hide_descriptors_with_only_new_releases"] + if not isinstance(hide_descriptors_with_only_new_releases, bool): + raise ValueError( + f"Must be a bool: got {type(hide_descriptors_with_only_new_releases)}" + ) + except KeyError: + hide_descriptors_with_only_new_releases = False + except ValueError as e: + raise InvalidConfigValueError( + f"Invalid value for hide_descriptors_with_only_new_releases in configuration file ({cfgpath}): {e}" + ) from e + + try: + hide_labels_with_only_new_releases = data["hide_labels_with_only_new_releases"] + del data["hide_labels_with_only_new_releases"] + if not isinstance(hide_labels_with_only_new_releases, bool): + raise ValueError(f"Must be a bool: got {type(hide_labels_with_only_new_releases)}") + except KeyError: + hide_labels_with_only_new_releases = False + except ValueError as e: + raise InvalidConfigValueError( + f"Invalid value for hide_labels_with_only_new_releases in configuration file ({cfgpath}): {e}" + ) from e + try: cover_art_stems = data["cover_art_stems"] del data["cover_art_stems"] @@ -515,6 +559,9 @@ def parse(cls, config_path_override: Path | None = None) -> Config: fuse_genres_blacklist=fuse_genres_blacklist, fuse_descriptors_blacklist=fuse_descriptors_blacklist, fuse_labels_blacklist=fuse_labels_blacklist, + hide_genres_with_only_new_releases=hide_genres_with_only_new_releases, + hide_descriptors_with_only_new_releases=hide_descriptors_with_only_new_releases, + hide_labels_with_only_new_releases=hide_labels_with_only_new_releases, cover_art_stems=cover_art_stems, valid_art_exts=valid_art_exts, max_filename_bytes=max_filename_bytes, diff --git a/rose/config_test.py b/rose/config_test.py index 5940fb1..f6c86ea 100644 --- a/rose/config_test.py +++ b/rose/config_test.py @@ -52,10 +52,16 @@ def test_config_full() -> None: {{ artist = "Abakus", aliases = ["Cinnamon Chasers"] }}, {{ artist = "tripleS", aliases = ["EVOLution", "LOVElution", "+(KR)ystal Eyes", "Acid Angel From Asia", "Acid Eyes"] }}, ] + fuse_artists_blacklist = [ "www" ] fuse_genres_blacklist = [ "xxx" ] fuse_descriptors_blacklist = [ "yyy" ] fuse_labels_blacklist = [ "zzz" ] + + hide_genres_with_only_new_releases = true + hide_descriptors_with_only_new_releases = true + hide_labels_with_only_new_releases = true + cover_art_stems = [ "aa", "bb" ] valid_art_exts = [ "tiff" ] max_filename_bytes = 255 @@ -122,6 +128,9 @@ def test_config_full() -> None: fuse_genres_whitelist=None, fuse_descriptors_whitelist=None, fuse_labels_whitelist=None, + hide_genres_with_only_new_releases=True, + hide_descriptors_with_only_new_releases=True, + hide_labels_with_only_new_releases=True, fuse_artists_blacklist=["www"], fuse_genres_blacklist=["xxx"], fuse_descriptors_blacklist=["yyy"], @@ -595,3 +604,30 @@ def write(x: str) -> None: str(excinfo.value) == f"Invalid value for rename_source_files in configuration file ({path}): Must be a bool: got " ) + + # hide_genres_with_only_new_releases + write(config + '\nhide_genres_with_only_new_releases = "lalala"') + with pytest.raises(InvalidConfigValueError) as excinfo: + Config.parse(config_path_override=path) + assert ( + str(excinfo.value) + == f"Invalid value for hide_genres_with_only_new_releases in configuration file ({path}): Must be a bool: got " + ) + + # hide_descriptors_with_only_new_releases + write(config + '\nhide_descriptors_with_only_new_releases = "lalala"') + with pytest.raises(InvalidConfigValueError) as excinfo: + Config.parse(config_path_override=path) + assert ( + str(excinfo.value) + == f"Invalid value for hide_descriptors_with_only_new_releases in configuration file ({path}): Must be a bool: got " + ) + + # hide_labels_with_only_new_releases + write(config + '\nhide_labels_with_only_new_releases = "lalala"') + with pytest.raises(InvalidConfigValueError) as excinfo: + Config.parse(config_path_override=path) + assert ( + str(excinfo.value) + == f"Invalid value for hide_labels_with_only_new_releases in configuration file ({path}): Must be a bool: got " + ) diff --git a/rose/playlists_test.py b/rose/playlists_test.py index cd18182..a41ad9e 100644 --- a/rose/playlists_test.py +++ b/rose/playlists_test.py @@ -219,7 +219,7 @@ def test_dump_playlist(config: Config) -> None: "releasedate": "2021", "compositiondate": None, "catalognumber": "DG-001", - "new": False, + "new": True, "genres": ["Classical"], "parent_genres": [], "labels": ["Native State"], @@ -341,7 +341,7 @@ def test_dump_playlists(config: Config) -> None: "releasedate": "2021", "compositiondate": None, "catalognumber": "DG-001", - "new": False, + "new": True, "genres": ["Classical"], "parent_genres": [], "labels": ["Native State"], diff --git a/rose/releases_test.py b/rose/releases_test.py index b25998d..af52791 100644 --- a/rose/releases_test.py +++ b/rose/releases_test.py @@ -639,7 +639,7 @@ def test_dump_releases(config: Config) -> None: "releasedate": "2021", "compositiondate": None, "catalognumber": "DG-001", - "new": False, + "new": True, "disctotal": 1, "genres": ["Classical"], "parent_genres": [], @@ -692,7 +692,7 @@ def test_dump_releases(config: Config) -> None: "releasedate": "2021-04-20", "compositiondate": "1780", "catalognumber": "DG-002", - "new": True, + "new": False, "disctotal": 1, "genres": [], "parent_genres": [], @@ -749,7 +749,7 @@ def test_dump_releases_matcher(config: Config) -> None: "releasedate": "2021", "compositiondate": None, "catalognumber": "DG-001", - "new": False, + "new": True, "disctotal": 1, "genres": ["Classical"], "parent_genres": [], diff --git a/rose/tracks_test.py b/rose/tracks_test.py index b96555b..f80b73d 100644 --- a/rose/tracks_test.py +++ b/rose/tracks_test.py @@ -168,7 +168,7 @@ def test_dump_tracks(config: Config) -> None: "releasedate": "2021", "compositiondate": None, "catalognumber": "DG-001", - "new": False, + "new": True, "genres": ["Classical"], "parent_genres": [], "labels": ["Native State"], @@ -215,7 +215,7 @@ def test_dump_tracks(config: Config) -> None: "releasedate": "2021-04-20", "compositiondate": "1780", "catalognumber": "DG-002", - "new": True, + "new": False, "genres": [], "parent_genres": [], "labels": [], diff --git a/rose_vfs/virtualfs.py b/rose_vfs/virtualfs.py index 978f91a..53cb917 100644 --- a/rose_vfs/virtualfs.py +++ b/rose_vfs/virtualfs.py @@ -1066,24 +1066,30 @@ def readdir(self, p: VirtualPath) -> Iterator[tuple[str, dict[str, Any]]]: return if p.view == "Genres": - for genre in list_genres(self.config): - if not self.can_show.genre(genre): + for e1 in list_genres(self.config): + if not self.can_show.genre(e1.genre): continue - yield self.sanitizer.sanitize(genre), self.stat("dir") + if self.config.hide_genres_with_only_new_releases and e1.only_new_releases: + continue + yield self.sanitizer.sanitize(e1.genre), self.stat("dir") return if p.view == "Descriptors": - for descriptor in list_descriptors(self.config): - if not self.can_show.descriptor(descriptor): + for e2 in list_descriptors(self.config): + if not self.can_show.descriptor(e2.descriptor): + continue + if self.config.hide_descriptors_with_only_new_releases and e2.only_new_releases: continue - yield self.sanitizer.sanitize(descriptor), self.stat("dir") + yield self.sanitizer.sanitize(e2.descriptor), self.stat("dir") return if p.view == "Labels": - for label in list_labels(self.config): - if not self.can_show.label(label): + for e3 in list_labels(self.config): + if not self.can_show.label(e3.label): + continue + if self.config.hide_labels_with_only_new_releases and e3.only_new_releases: continue - yield self.sanitizer.sanitize(label), self.stat("dir") + yield self.sanitizer.sanitize(e3.label), self.stat("dir") return if p.view == "Collages" and p.collage: diff --git a/rose_vfs/virtualfs_test.py b/rose_vfs/virtualfs_test.py index 1410089..174a0fa 100644 --- a/rose_vfs/virtualfs_test.py +++ b/rose_vfs/virtualfs_test.py @@ -15,8 +15,8 @@ from rose_vfs.virtualfs import mount_virtualfs, unmount_virtualfs R1_VNAME = "Techno Man & Bass Man - 2023. Release 1" -R2_VNAME = "Violin Woman (feat. Conductor Woman) - 2021. Release 2" -R3_VNAME = "Unknown Artists - 2021. Release 3 [NEW]" +R2_VNAME = "Violin Woman (feat. Conductor Woman) - 2021. Release 2 [NEW]" +R3_VNAME = "Unknown Artists - 2021. Release 3" @contextmanager @@ -60,10 +60,10 @@ def can_read(p: Path) -> bool: assert can_read(root / "1. Releases" / R2_VNAME / ".rose.r2.toml") assert (root / "1. Releases - New").is_dir() - assert (root / "1. Releases - New" / R3_VNAME).is_dir() - assert not (root / "1. Releases - New" / R2_VNAME).exists() - assert (root / "1. Releases - New" / R3_VNAME / "01. Track 1.m4a").is_file() - assert not (root / "1. Releases - New" / R3_VNAME / "lalala").exists() + assert (root / "1. Releases - New" / R2_VNAME).is_dir() + assert not (root / "1. Releases - New" / R3_VNAME).exists() + assert (root / "1. Releases - New" / R2_VNAME / "01. Track 1 (feat. Conductor Woman).m4a").is_file() + assert not (root / "1. Releases - New" / R2_VNAME / "lalala").exists() assert (root / "1. Releases - Added On").is_dir() assert (root / "1. Releases - Added On" / f"[0000-01-01] {R2_VNAME}").exists() @@ -425,3 +425,21 @@ def test_virtual_filesystem_whitelist(config: Config) -> None: assert (root / "3. Genres" / "Techno").is_dir() assert (root / "4. Descriptors" / "Warm").is_dir() assert (root / "5. Labels" / "Silk Music").is_dir() + + +@pytest.mark.usefixtures("seeded_cache") +def test_virtual_filesystem_hide_new_release_classifiers(config: Config) -> None: + new_config = dataclasses.replace( + config, + hide_genres_with_only_new_releases=True, + hide_descriptors_with_only_new_releases=True, + hide_labels_with_only_new_releases=True, + ) + root = config.fuse_mount_dir + with start_virtual_fs(new_config): + assert not (root / "3. Genres" / "Classical").exists() + assert not (root / "4. Descriptors" / "Wet").exists() + assert not (root / "5. Labels" / "Native State").exists() + assert (root / "3. Genres" / "Deep House").is_dir() + assert (root / "4. Descriptors" / "Warm").is_dir() + assert (root / "5. Labels" / "Silk Music").is_dir()