diff --git a/conftest.py b/conftest.py index f2a38e4..82664ee 100644 --- a/conftest.py +++ b/conftest.py @@ -83,7 +83,7 @@ def seeded_cache(config: Config) -> None: f"""\ INSERT INTO releases (id , source_path , cover_image_path , datafile_mtime, virtual_dirname, title , release_type, release_year, multidisc, new , formatted_artists) -VALUES ('r1', '{dirpaths[0]}', null , '999' , 'r1' , 'Release 1', 'album' , 2023 , false , true , 'Techno Man;Bass Man') +VALUES ('r1', '{dirpaths[0]}', null , '999' , 'r1' , 'Release 1', 'album' , 2023 , false , false , 'Techno Man;Bass Man') , ('r2', '{dirpaths[1]}', '{imagepaths[0]}', '999' , 'r2' , 'Release 2', 'album' , 2021 , false , false, 'Violin Woman feat. Conductor Woman'); INSERT INTO releases_genres diff --git a/rose/__main__.py b/rose/__main__.py index 9a69182..528c558 100644 --- a/rose/__main__.py +++ b/rose/__main__.py @@ -13,7 +13,7 @@ edit_collage_in_editor, ) from rose.config import Config -from rose.releases import dump_releases +from rose.releases import dump_releases, toggle_release_new from rose.virtualfs import mount_virtualfs, unmount_virtualfs from rose.watcher import start_watchdog @@ -102,6 +102,17 @@ def print1(ctx: Context) -> None: print(dump_releases(ctx.config)) +@releases.command() +@click.argument("release", type=str, nargs=1) +@click.pass_obj +def toggle_new(ctx: Context, release: str) -> None: + """ + Toggle whether a release is new. Accepts a release's UUID or virtual fs dirname (both are + accepted). + """ + toggle_release_new(ctx.config, release) + + @cli.group() def collages() -> None: """Manage collages.""" diff --git a/rose/cache.py b/rose/cache.py index 4472474..d06e8d3 100644 --- a/rose/cache.py +++ b/rose/cache.py @@ -577,9 +577,8 @@ def update_cache_for_releases( release_virtual_dirname += " [" + ";".join(release.genres) + "]" if release.labels: release_virtual_dirname += " {" + ";".join(release.labels) + "}" - # Reimplement this once we have new toggling. - # if release.new: - # release_virtual_dirname += " +NEW!+" + if release.new: + release_virtual_dirname = "[NEW] " + release_virtual_dirname release_virtual_dirname = sanitize_filename(release_virtual_dirname) # And in case of a name collision, add an extra number at the end. Iterate to # find the first unused number. @@ -1203,12 +1202,12 @@ def list_collages(c: Config) -> Iterator[str]: yield row["name"] -def list_collage_releases(c: Config, collage_name: str) -> Iterator[tuple[int, str, Path]]: - """Returns tuples of (position, release_virtual_dirname, release_source_path).""" +def list_collage_releases(c: Config, collage_name: str) -> Iterator[tuple[int, str, Path, bool]]: + """Returns tuples of (position, release_virtual_dirname, release_source_path, release_new).""" with connect(c) as conn: cursor = conn.execute( """ - SELECT cr.position, r.virtual_dirname, r.source_path + SELECT cr.position, r.virtual_dirname, r.source_path, r.new FROM collages_releases cr JOIN releases r ON r.id = cr.release_id WHERE cr.collage_name = ? @@ -1217,7 +1216,12 @@ def list_collage_releases(c: Config, collage_name: str) -> Iterator[tuple[int, s (collage_name,), ) for row in cursor: - yield (row["position"], row["virtual_dirname"], Path(row["source_path"])) + yield ( + row["position"], + row["virtual_dirname"], + Path(row["source_path"]), + bool(row["new"]), + ) def release_exists(c: Config, virtual_dirname: str) -> Path | None: diff --git a/rose/cache_test.py b/rose/cache_test.py index 12c3cb2..cea5c0e 100644 --- a/rose/cache_test.py +++ b/rose/cache_test.py @@ -379,7 +379,7 @@ def test_list_releases(config: Config) -> None: type="album", year=2023, multidisc=False, - new=True, + new=False, genres=["Deep House", "Techno"], labels=["Silk Music"], artists=[ @@ -421,7 +421,7 @@ def test_list_releases(config: Config) -> None: type="album", year=2023, multidisc=False, - new=True, + new=False, genres=["Deep House", "Techno"], labels=["Silk Music"], artists=[ @@ -444,7 +444,7 @@ def test_list_releases(config: Config) -> None: type="album", year=2023, multidisc=False, - new=True, + new=False, genres=["Deep House", "Techno"], labels=["Silk Music"], artists=[ @@ -467,7 +467,7 @@ def test_list_releases(config: Config) -> None: type="album", year=2023, multidisc=False, - new=True, + new=False, genres=["Deep House", "Techno"], labels=["Silk Music"], artists=[ @@ -522,8 +522,8 @@ def test_list_collages(config: Config) -> None: def test_list_collage_releases(config: Config) -> None: releases = list(list_collage_releases(config, "Rose Gold")) assert set(releases) == { - (0, "r1", config.music_source_dir / "r1"), - (1, "r2", config.music_source_dir / "r2"), + (0, "r1", config.music_source_dir / "r1", False), + (1, "r2", config.music_source_dir / "r2", False), } releases = list(list_collage_releases(config, "Ruby Red")) assert releases == [] diff --git a/rose/collages.py b/rose/collages.py index f06b574..08db28a 100644 --- a/rose/collages.py +++ b/rose/collages.py @@ -90,7 +90,7 @@ def dump_collages(c: Config) -> str: collage_names = list(list_collages(c)) for name in collage_names: out[name] = [] - for pos, virtual_dirname, _ in list_collage_releases(c, name): + for pos, virtual_dirname, _, _ in list_collage_releases(c, name): out[name].append({"position": pos, "release": virtual_dirname}) return json.dumps(out) diff --git a/rose/releases.py b/rose/releases.py index e870f83..ad0147c 100644 --- a/rose/releases.py +++ b/rose/releases.py @@ -4,15 +4,19 @@ from pathlib import Path from typing import Any +import tomli_w +import tomllib from send2trash import send2trash from rose.cache import ( + STORED_DATA_FILE_REGEX, get_release_id_from_virtual_dirname, get_release_source_path_from_id, get_release_virtual_dirname_from_id, list_releases, update_cache_evict_nonexistent_releases, update_cache_for_collages, + update_cache_for_releases, ) from rose.common import RoseError, valid_uuid from rose.config import Config @@ -49,6 +53,28 @@ def delete_release(c: Config, release_id_or_virtual_dirname: str) -> None: update_cache_for_collages(c, None, force=True) +def toggle_release_new(c: Config, release_id_or_virtual_dirname: str) -> None: + release_id, release_dirname = resolve_release_ids(c, release_id_or_virtual_dirname) + source_path = get_release_source_path_from_id(c, release_id) + if source_path is None: + logger.debug(f"Failed to lookup source path for release {release_id} ({release_dirname})") + return None + + for f in source_path.iterdir(): + if not STORED_DATA_FILE_REGEX.match(f.name): + continue + + with f.open("rb") as fp: + data = tomllib.load(fp) + data["new"] = not data["new"] + with f.open("wb") as fp: + tomli_w.dump(data, fp) + update_cache_for_releases(c, [source_path], force=True) + return + + logger.critical(f"Failed to find .rose.toml in {source_path}") + + def resolve_release_ids(c: Config, release_id_or_virtual_dirname: str) -> tuple[str, str]: if valid_uuid(release_id_or_virtual_dirname): uuid = release_id_or_virtual_dirname diff --git a/rose/releases_test.py b/rose/releases_test.py index e1618cb..5e76cb5 100644 --- a/rose/releases_test.py +++ b/rose/releases_test.py @@ -1,6 +1,7 @@ import shutil import pytest +import tomllib from conftest import TEST_RELEASE_1 from rose.cache import connect, update_cache @@ -10,6 +11,7 @@ delete_release, dump_releases, resolve_release_ids, + toggle_release_new, ) @@ -30,6 +32,33 @@ def test_delete_release(config: Config) -> None: assert cursor.fetchone()[0] == 0 +def test_toggle_release_new(config: Config) -> None: + shutil.copytree(TEST_RELEASE_1, config.music_source_dir / TEST_RELEASE_1.name) + update_cache(config) + with connect(config) as conn: + cursor = conn.execute("SELECT id, virtual_dirname FROM releases") + release_id = cursor.fetchone()["id"] + datafile = config.music_source_dir / TEST_RELEASE_1.name / f".rose.{release_id}.toml" + + # Set not new. + toggle_release_new(config, release_id) + with datafile.open("rb") as fp: + data = tomllib.load(fp) + assert data["new"] is False + with connect(config) as conn: + cursor = conn.execute("SELECT virtual_dirname FROM releases") + assert not cursor.fetchone()["virtual_dirname"].startswith("[NEW] ") + + # Set new. + toggle_release_new(config, release_id) + with datafile.open("rb") as fp: + data = tomllib.load(fp) + assert data["new"] is True + with connect(config) as conn: + cursor = conn.execute("SELECT virtual_dirname FROM releases") + assert cursor.fetchone()["virtual_dirname"].startswith("[NEW] ") + + def test_resolve_release_ids(config: Config) -> None: shutil.copytree(TEST_RELEASE_1, config.music_source_dir / TEST_RELEASE_1.name) update_cache(config) diff --git a/rose/virtualfs.py b/rose/virtualfs.py index 420fddc..b8414cb 100644 --- a/rose/virtualfs.py +++ b/rose/virtualfs.py @@ -39,7 +39,7 @@ ) from rose.common import sanitize_filename from rose.config import Config -from rose.releases import ReleaseDoesNotExistError, delete_release +from rose.releases import ReleaseDoesNotExistError, delete_release, toggle_release_new logger = logging.getLogger(__name__) @@ -149,11 +149,11 @@ def readdir(self, path: str, _: int) -> Iterator[str]: sanitized_genre_filter=p.genre, sanitized_label_filter=p.label, ): - yield release.virtual_dirname - self.getattr_cache[path + "/" + release.virtual_dirname] = ( - time.time(), - ("dir", release.source_path), - ) + v = release.virtual_dirname + if release.new: + v = "[NEW] " + v + yield v + self.getattr_cache[path + "/" + v] = (time.time(), ("dir", release.source_path)) elif p.view == "Artists": for artist in list_artists(self.config): if artist in self.hide_artists_set: @@ -175,10 +175,12 @@ def readdir(self, path: str, _: int) -> Iterator[str]: elif p.view == "Collages" and p.collage: releases = list(list_collage_releases(self.config, p.collage)) pad_size = max(len(str(r[0])) for r in releases) - for idx, virtual_dirname, source_dir in releases: - dirname = f"{str(idx).zfill(pad_size)}. {virtual_dirname}" - yield dirname - self.getattr_cache[path + "/" + dirname] = (time.time(), ("dir", source_dir)) + for idx, v, source_dir, new in releases: + if new: + v = f"[NEW] {v}" + v = f"{str(idx).zfill(pad_size)}. {v}" + yield v + self.getattr_cache[path + "/" + v] = (time.time(), ("dir", source_dir)) elif p.view == "Collages": # Don't need to sanitize because the collage names come from filenames. for collage in list_collages(self.config): @@ -236,6 +238,7 @@ def release(self, path: str, fh: int) -> None: def mkdir(self, path: str, mode: int) -> None: logger.debug(f"Received mkdir for {path=} {mode=}") p = parse_virtual_path(path) + logger.debug(f"Parsed mkdir path as {p}") # Possible actions: # 1. Add a release to an existing collage. @@ -258,6 +261,7 @@ def mkdir(self, path: str, mode: int) -> None: def rmdir(self, path: str) -> None: logger.debug(f"Received rmdir for {path=}") p = parse_virtual_path(path) + logger.debug(f"Parsed rmdir path as {p}") # Possible actions: # 1. Delete a release from an existing collage. @@ -277,12 +281,28 @@ def rmdir(self, path: str) -> None: def rename(self, old: str, new: str) -> None: logger.debug(f"Received rename for {old=} {new=}") op = parse_virtual_path(old) + logger.debug(f"Parsed rename old path as {op}") np = parse_virtual_path(new) + logger.debug(f"Parsed rename new path as {np}") # Possible actions: - # 1. Rename a collage - if op.view == "Collages" and np.view == "Collages": - if op.collage and np.collage and not op.release and not np.release: + # 1. Rename a collage. + # 2. Toggle a release's new status. + if ( + (op.release and np.release) + and op.release.removeprefix("[NEW] ") == np.release.removeprefix("[NEW] ") + and (not op.file and not np.file) + ): + if op.release.startswith("[NEW] ") != np.release.startswith("[NEW] "): + toggle_release_new(self.config, op.release) + else: + raise fuse.FuseOSError(errno.EACCES) + elif op.view == "Collages" and np.view == "Collages": + if ( + (op.collage and np.collage) + and op.collage != np.collage + and (not op.release and not np.release) + ): rename_collage(self.config, op.collage, np.collage) else: raise fuse.FuseOSError(errno.EACCES) @@ -397,7 +417,10 @@ def parse_virtual_path(path: str) -> ParsedPath: return ParsedPath(view="Collages", collage=parts[1], release=rm_position(parts[2])) if len(parts) == 4: return ParsedPath( - view="Collages", collage=parts[1], release=rm_position(parts[2]), file=parts[3] + view="Collages", + collage=parts[1], + release=rm_position(parts[2]), + file=parts[3], ) raise fuse.FuseOSError(errno.ENOENT) diff --git a/rose/virtualfs_test.py b/rose/virtualfs_test.py index 9823a98..aed5021 100644 --- a/rose/virtualfs_test.py +++ b/rose/virtualfs_test.py @@ -127,6 +127,20 @@ def test_virtual_filesystem_collage_actions(config: Config) -> None: assert not (src / "!collages" / "New Jeans.toml").exists() +def test_virtual_filesystem_toggle_new(config: Config, source_dir: Path) -> None: + dirname = "NewJeans - 1990. I Love NewJeans [K-Pop;R&B] {A Cool Label}" + root = config.fuse_mount_dir + with startfs(config): + (root / "Releases" / dirname).rename(root / "Releases" / f"[NEW] {dirname}") + assert (root / "Releases" / f"[NEW] {dirname}").is_dir() + assert not (root / "Releases" / dirname).exists() + (root / "Releases" / f"[NEW] {dirname}").rename(root / "Releases" / dirname) + assert (root / "Releases" / dirname).is_dir() + assert not (root / "Releases" / f"[NEW] {dirname}").exists() + with pytest.raises(OSError): # noqa: PT011 + (root / "Releases" / dirname).rename(root / "Releases" / "lalala") + + @pytest.mark.usefixtures("seeded_cache") def test_virtual_filesystem_hide_values(config: Config) -> None: new_config = Config( diff --git a/testdata/cache/Test Release 2/.rose.ilovecarly.toml b/testdata/cache/Test Release 2/.rose.ilovecarly.toml index 8d40824..90f2c0f 100644 --- a/testdata/cache/Test Release 2/.rose.ilovecarly.toml +++ b/testdata/cache/Test Release 2/.rose.ilovecarly.toml @@ -1 +1 @@ -new = true +new = false diff --git a/testdata/cache/Test Release 3/.rose.ilovenewjeans.toml b/testdata/cache/Test Release 3/.rose.ilovenewjeans.toml index 8d40824..90f2c0f 100644 --- a/testdata/cache/Test Release 3/.rose.ilovenewjeans.toml +++ b/testdata/cache/Test Release 3/.rose.ilovenewjeans.toml @@ -1 +1 @@ -new = true +new = false