diff --git a/conftest.py b/conftest.py index c5627f9..b693b49 100644 --- a/conftest.py +++ b/conftest.py @@ -227,6 +227,7 @@ def _seed_cache(config: Config, mkfiles: bool) -> None: , label , releaseartist , trackartist + , new ) SELECT t.rowid @@ -246,6 +247,7 @@ def _seed_cache(config: Config, mkfiles: bool) -> None: , process_string_for_fts(COALESCE(GROUP_CONCAT(rl.label, ' '), '')) AS label , process_string_for_fts(COALESCE(GROUP_CONCAT(ra.artist, ' '), '')) AS releaseartist , process_string_for_fts(COALESCE(GROUP_CONCAT(ta.artist, ' '), '')) AS trackartist + , process_string_for_fts(CASE WHEN r.new THEN 'true' ELSE 'false' END) AS new FROM tracks t JOIN releases r ON r.id = t.release_id LEFT JOIN releases_genres rg ON rg.release_id = r.id diff --git a/docs/METADATA_TOOLS.md b/docs/METADATA_TOOLS.md index e3c63b3..95f4471 100644 --- a/docs/METADATA_TOOLS.md +++ b/docs/METADATA_TOOLS.md @@ -327,12 +327,15 @@ The rules engine supports matching and acting on the following tags: - `label` - `catalognumber` - `edition` +- `new` The `trackartist[*]`, `releaseartist[*]`, `genre` (& parents), `secondarygenre` (& parents), `descriptor`, and `label` tags are _multi-value_ tags, which have a slightly different behavior from single-value tags for some of the actions. We'll explore this difference in the [Actions](#actions) section. +The `new` tag will always be the string `true` or `false`. + For convenience, the rules parser also allows you to specify _tag aliases_ in place of the above tags, which expand to multiple tags when matching. The supported aliases are: diff --git a/rose/cache.py b/rose/cache.py index 6cf4c2f..98cf6c6 100644 --- a/rose/cache.py +++ b/rose/cache.py @@ -1243,6 +1243,7 @@ def _update_cache_for_releases_executor( , label , releaseartist , trackartist + , new ) SELECT t.rowid @@ -1264,6 +1265,7 @@ def _update_cache_for_releases_executor( , process_string_for_fts(COALESCE(GROUP_CONCAT(rl.label, ' '), '')) AS label , process_string_for_fts(COALESCE(GROUP_CONCAT(ra.artist, ' '), '')) AS releaseartist , process_string_for_fts(COALESCE(GROUP_CONCAT(ta.artist, ' '), '')) AS trackartist + , process_string_for_fts(CASE WHEN r.new THEN 'true' ELSE 'false' END) AS new FROM tracks t JOIN releases r ON r.id = t.release_id LEFT JOIN releases_genres rg ON rg.release_id = r.id diff --git a/rose/cache.sql b/rose/cache.sql index b5f8b1a..cd9ee2e 100644 --- a/rose/cache.sql +++ b/rose/cache.sql @@ -183,6 +183,7 @@ CREATE VIRTUAL TABLE rules_engine_fts USING fts5 ( , label , releaseartist , trackartist + , new -- Use standard unicode tokenizer; do not remove diacritics; treat everything we know as token. -- Except for the ¬, which is our "separator." We use that separator to produce single-character -- tokens. diff --git a/rose/rule_parser.py b/rose/rule_parser.py index e693784..6e33592 100644 --- a/rose/rule_parser.py +++ b/rose/rule_parser.py @@ -7,11 +7,11 @@ from __future__ import annotations -from collections.abc import Sequence import io import logging import re import shlex +from collections.abc import Sequence from dataclasses import dataclass from typing import Literal @@ -75,6 +75,7 @@ def __str__(self) -> str: "secondarygenre", "descriptor", "label", + "new", ] ExpandableTag = Tag | Literal["artist", "trackartist", "releaseartist"] @@ -130,6 +131,7 @@ def __str__(self) -> str: "secondarygenre": ["secondarygenre"], "descriptor": ["descriptor"], "label": ["label"], + "new": ["new"], "artist": [ "trackartist[main]", "trackartist[guest]", @@ -177,6 +179,7 @@ def __str__(self) -> str: "secondarygenre", "descriptor", "label", + "new", ] SINGLE_VALUE_TAGS: list[Tag] = [ @@ -192,6 +195,7 @@ def __str__(self) -> str: "compositiondate", "edition", "catalognumber", + "new", ] RELEASE_TAGS: list[Tag] = [ @@ -215,6 +219,7 @@ def __str__(self) -> str: "descriptor", "label", "disctotal", + "new", ] diff --git a/rose/rule_parser_test.py b/rose/rule_parser_test.py index 1c8a575..5fb2dd4 100644 --- a/rose/rule_parser_test.py +++ b/rose/rule_parser_test.py @@ -77,7 +77,7 @@ def test_err(rule: str, err: str) -> None: tracknumber^Track$ ^ - Invalid tag: must be one of {tracktitle, trackartist, trackartist[main], trackartist[guest], trackartist[remixer], trackartist[producer], trackartist[composer], trackartist[conductor], trackartist[djmixer], tracknumber, tracktotal, discnumber, disctotal, releasetitle, releaseartist, releaseartist[main], releaseartist[guest], releaseartist[remixer], releaseartist[producer], releaseartist[composer], releaseartist[conductor], releaseartist[djmixer], releasetype, releasedate, originaldate, compositiondate, edition, catalognumber, genre, secondarygenre, descriptor, label, artist}. The next character after a tag must be ':' or ','. + Invalid tag: must be one of {tracktitle, trackartist, trackartist[main], trackartist[guest], trackartist[remixer], trackartist[producer], trackartist[composer], trackartist[conductor], trackartist[djmixer], tracknumber, tracktotal, discnumber, disctotal, releasetitle, releaseartist, releaseartist[main], releaseartist[guest], releaseartist[remixer], releaseartist[producer], releaseartist[composer], releaseartist[conductor], releaseartist[djmixer], releasetype, releasedate, originaldate, compositiondate, edition, catalognumber, genre, secondarygenre, descriptor, label, new, artist}. The next character after a tag must be ':' or ','. """, ) @@ -250,7 +250,7 @@ def test_err(rule: str, err: str, matcher: MetadataMatcher | None = None) -> Non haha/delete ^ - Invalid tag: must be one of {tracktitle, trackartist, trackartist[main], trackartist[guest], trackartist[remixer], trackartist[producer], trackartist[composer], trackartist[conductor], trackartist[djmixer], tracknumber, discnumber, releasetitle, releaseartist, releaseartist[main], releaseartist[guest], releaseartist[remixer], releaseartist[producer], releaseartist[composer], releaseartist[conductor], releaseartist[djmixer], releasetype, releasedate, originaldate, compositiondate, edition, catalognumber, genre, secondarygenre, descriptor, label, artist}. The next character after a tag must be ':' or ','. + Invalid tag: must be one of {tracktitle, trackartist, trackartist[main], trackartist[guest], trackartist[remixer], trackartist[producer], trackartist[composer], trackartist[conductor], trackartist[djmixer], tracknumber, discnumber, releasetitle, releaseartist, releaseartist[main], releaseartist[guest], releaseartist[remixer], releaseartist[producer], releaseartist[composer], releaseartist[conductor], releaseartist[djmixer], releasetype, releasedate, originaldate, compositiondate, edition, catalognumber, genre, secondarygenre, descriptor, label, new, artist}. The next character after a tag must be ':' or ','. """, ) @@ -261,7 +261,7 @@ def test_err(rule: str, err: str, matcher: MetadataMatcher | None = None) -> Non tracktitler/delete ^ - Invalid tag: must be one of {tracktitle, trackartist, trackartist[main], trackartist[guest], trackartist[remixer], trackartist[producer], trackartist[composer], trackartist[conductor], trackartist[djmixer], tracknumber, discnumber, releasetitle, releaseartist, releaseartist[main], releaseartist[guest], releaseartist[remixer], releaseartist[producer], releaseartist[composer], releaseartist[conductor], releaseartist[djmixer], releasetype, releasedate, originaldate, compositiondate, edition, catalognumber, genre, secondarygenre, descriptor, label, artist}. The next character after a tag must be ':' or ','. + Invalid tag: must be one of {tracktitle, trackartist, trackartist[main], trackartist[guest], trackartist[remixer], trackartist[producer], trackartist[composer], trackartist[conductor], trackartist[djmixer], tracknumber, discnumber, releasetitle, releaseartist, releaseartist[main], releaseartist[guest], releaseartist[remixer], releaseartist[producer], releaseartist[composer], releaseartist[conductor], releaseartist[djmixer], releasetype, releasedate, originaldate, compositiondate, edition, catalognumber, genre, secondarygenre, descriptor, label, new, artist}. The next character after a tag must be ':' or ','. """, ) diff --git a/rose_vfs/virtualfs.py b/rose_vfs/virtualfs.py index 9d40094..602373d 100644 --- a/rose_vfs/virtualfs.py +++ b/rose_vfs/virtualfs.py @@ -93,6 +93,7 @@ list_playlists, make_release_logtext, make_track_logtext, + release_within_collage, remove_release_from_collage, remove_track_from_playlist, rename_collage, @@ -105,10 +106,8 @@ track_within_release, update_cache_for_releases, ) -from rose.cache import ( - list_releases_delete_this, - release_within_collage, -) +from rose.cache import list_releases, list_tracks +from rose.releases import find_releases_matching_rule from rose.rule_parser import MatcherPattern, MetadataMatcher from rose.tracks import find_tracks_matching_rule @@ -1089,17 +1088,33 @@ def readdir(self, p: VirtualPath) -> Iterator[tuple[str, dict[str, Any]]]: return if p.release == ALL_TRACKS: + matcher = None if p.artist: - matcher = MetadataMatcher(tags=["artist"], pattern=MatcherPattern(f"^{p.artist}$")) + matcher = MetadataMatcher( + tags=["artist"], + pattern=MatcherPattern(f"^{p.artist}$"), + ) if p.genre: - matcher = MetadataMatcher(tags=["genre"], pattern=MatcherPattern(f"^{p.genre}$")) + matcher = MetadataMatcher( + tags=["genre"], + pattern=MatcherPattern(f"^{p.genre}$"), + ) if p.descriptor: matcher = MetadataMatcher( - tags=["descriptor"], pattern=MatcherPattern(f"^{p.descriptor}$") + tags=["descriptor"], + pattern=MatcherPattern(f"^{p.descriptor}$"), ) if p.label: - matcher = MetadataMatcher(tags=["label"], pattern=MatcherPattern(f"^{p.label}$")) - tracks = find_tracks_matching_rule(self.config, matcher) + matcher = MetadataMatcher( + tags=["label"], + pattern=MatcherPattern(f"^{p.label}$"), + ) + + tracks = ( + find_tracks_matching_rule(self.config, matcher) + if matcher + else list_tracks(self.config) + ) for trk, vname in self.vnames.list_track_paths(p, tracks): yield vname, self.stat("file", trk.source_path) return @@ -1124,19 +1139,37 @@ def readdir(self, p: VirtualPath) -> Iterator[tuple[str, dict[str, Any]]]: or p.label or p.view in ["Releases", "New", "Added On", "Released On"] ): - # fmt: off - releases = list_releases_delete_this( - self.config, - artist_filter=self.sanitizer.unsanitize(p.artist, p.artist_parent) if p.artist else None, - genre_filter=self.sanitizer.unsanitize(p.genre, p.genre_parent) if p.genre else None, - descriptor_filter=self.sanitizer.unsanitize(p.descriptor, p.descriptor_parent) if p.descriptor else None, - label_filter=self.sanitizer.unsanitize(p.label, p.label_parent) if p.label else None, - new=True if p.view == "New" else None, + matcher = None + if p.artist: + matcher = MetadataMatcher( + tags=["releaseartist"], + pattern=MatcherPattern(f"^{p.artist}$"), + ) + if p.genre: + matcher = MetadataMatcher( + tags=["genre"], + pattern=MatcherPattern(f"^{p.genre}$"), + ) + if p.descriptor: + matcher = MetadataMatcher( + tags=["descriptor"], + pattern=MatcherPattern(f"^{p.descriptor}$"), + ) + if p.label: + matcher = MetadataMatcher( + tags=["label"], + pattern=MatcherPattern(f"^{p.label}$"), + ) + + releases = ( + find_releases_matching_rule(self.config, matcher) + if matcher + else list_releases(self.config) ) - # fmt: on + # TODO: new=True if p.view == "New" else None, + yield ALL_TRACKS, self.stat("dir") for rls, vname in self.vnames.list_release_paths(p, releases): yield vname, self.stat("dir", rls.source_path) - yield ALL_TRACKS, self.stat("dir") return if p.view == "Artists":