From 0f444ce7dc0d97d8984f06a88150520043675d42 Mon Sep 17 00:00:00 2001 From: blissful Date: Wed, 25 Oct 2023 18:18:59 -0400 Subject: [PATCH] support adding file to playlist via cp --- rose/__init__.py | 6 +- rose/virtualfs.py | 123 ++++++++++++++++++++++++++++++++++++++--- rose/virtualfs_test.py | 34 +++++++----- 3 files changed, 136 insertions(+), 27 deletions(-) diff --git a/rose/__init__.py b/rose/__init__.py index c047cb9..010377b 100644 --- a/rose/__init__.py +++ b/rose/__init__.py @@ -12,11 +12,11 @@ # Useful for debugging problems with the virtual FS, since pytest doesn't capture that debug logging # output. -LOG_EVEN_THOUGH_WERE_IN_TEST = True +LOG_EVEN_THOUGH_WERE_IN_TEST = os.environ.get("LOG_TEST", False) # Add a logging handler for stdout unless we are testing. Pytest -# captures logging output on its own. -if "pytest" not in sys.modules or LOG_EVEN_THOUGH_WERE_IN_TEST: # pragma: no cover +# captures logging output on its own, so by default, we do not attach our own. +if "pytest" not in sys.modules or LOG_EVEN_THOUGH_WERE_IN_TEST: stream_formatter = logging.Formatter( "[%(asctime)s] %(levelname)s: %(message)s", datefmt="%H:%M:%S", diff --git a/rose/virtualfs.py b/rose/virtualfs.py index 2f23796..aac0a2e 100644 --- a/rose/virtualfs.py +++ b/rose/virtualfs.py @@ -5,6 +5,7 @@ import re import stat import subprocess +import tempfile import time from collections.abc import Iterator from dataclasses import dataclass @@ -41,18 +42,24 @@ ) from rose.config import Config from rose.playlists import ( + add_track_to_playlist, create_playlist, delete_playlist, remove_track_from_playlist, rename_playlist, ) from rose.releases import ReleaseDoesNotExistError, delete_release, toggle_release_new +from rose.tagger import AudioFile logger = logging.getLogger(__name__) -# I'm great at naming things. class CanShower: + """ + I'm great at naming things. This is "can show"-er, determining whether we can show an + artist/genre/label based on the configured whitelists and blacklists. + """ + def __init__(self, config: Config): self._config = config self._artist_w = None @@ -97,6 +104,21 @@ def label(self, label: str) -> bool: return True +class FileDescriptorGenerator: + """ + FileDescriptorGenerator generates file descriptors and handles wrapping so that we do not go + over the int size. Assumes that we do not cycle 10k file descriptors before the first descriptor + is released. + """ + + def __init__(self) -> None: + self._state = 0 + + def next(self) -> int: + self._state = self._state + 1 % 10_000 + return self._state + + # IDK how to get coverage on this thing. class VirtualFS(fuse.Operations): # type: ignore def __init__(self, config: Config): @@ -112,6 +134,26 @@ def __init__(self, config: Config): self.getattr_cache: dict[str, tuple[float, Any]] = {} # We use this object to determine whether we should show an artist/genre/label self.can_show = CanShower(config) + # We implement the "add track to playlist" operation in a slightly special way. Unlike + # releases, where the virtual dirname is globally unique, track filenames are not globally + # unique. Rather, they clash quite often. So instead of running a lookup on the virtual + # filename, we must instead inspect the bytes that get written upon copy, because within the + # copied audio file is the `track_id` tag (aka `roseid`). + # + # In order to be able to inspect the written bytes, we must store state across several + # syscalls (open, write, release). So the process goes: + # + # 1. Upon file open, if the syscall is intended to create a new file in a playlist, treat it + # as a playlist addition instead. Mock the file descriptor with an in-memory sentinel. + # 2. On subsequent write requests to the same path and sentinel file descriptor, take the + # bytes-to-write and store them in the in-memory state. + # 3. On release, write all the bytes to a temporary file and load the audio file up into an + # AudioFile dataclass (which parses tags). Look for the track ID tag, and if it exists, + # add it to the playlist. + # + # The state is a mapping of (path, fh) -> (playlist_name, ext, bytes). + self.playlist_additions_in_progress: dict[tuple[str, int], tuple[str, str, bytes]] = {} + self.fhgen = FileDescriptorGenerator() super().__init__() def getattr(self, path: str, fh: int | None) -> dict[str, Any]: @@ -127,6 +169,11 @@ def getattr(self, path: str, fh: int | None) -> dict[str, Any]: p = parse_virtual_path(path) logger.debug(f"Parsed getattr path as {p}") + # We need this here in order to support fgetattr during the file write operation. + if fh and self.playlist_additions_in_progress.get((path, fh), None): + logger.debug("Matched read to an in-progress playlist addition.") + return mkstat("file") + # Common logic that gets called for each release. def getattr_release(rp: Path) -> dict[str, Any]: assert p.release is not None @@ -256,7 +303,8 @@ def readdir(self, path: str, _: int) -> Iterator[str]: time.time(), ("file", release.cover_image_path), ) - raise fuse.FuseOSError(errno.ENOENT) from None + return + raise fuse.FuseOSError(errno.ENOENT) if p.artist or p.genre or p.label or p.view == "Releases" or p.view == "New": for release in list_releases( @@ -358,27 +406,61 @@ def open(self, path: str, flags: int) -> int: if track.virtual_filename == p.file: return os.open(str(track.source_path), flags) raise fuse.FuseOSError(err) - if p.playlist and p.file and p.file_position: - for idx, _, virtual_filename, tp in list_playlist_tracks(self.config, p.playlist): - if virtual_filename == p.file and idx == int(p.file_position): - return os.open(str(tp), flags) + if p.playlist and p.file: + if not playlist_exists(self.config, p.playlist): + raise fuse.FuseOSError(errno.ENOENT) + # If we are trying to create a file in the playlist, enter the "add file to playlist" + # operation sequence. See the __init__ for more details. + if flags & os.O_CREAT == os.O_CREAT: + fh = self.fhgen.next() + self.playlist_additions_in_progress[(path, fh)] = ( + p.playlist, + Path(p.file).suffix, + b"", + ) + return fh + # Otherwise, continue on... + if p.file_position: + for idx, _, virtual_filename, tp in list_playlist_tracks(self.config, p.playlist): + if virtual_filename == p.file and idx == int(p.file_position): + return os.open(str(tp), flags) + raise fuse.FuseOSError(err) raise fuse.FuseOSError(err) def read(self, path: str, length: int, offset: int, fh: int) -> bytes: logger.debug(f"Received read for {path=} {length=} {offset=} {fh=}") + + if pap := self.playlist_additions_in_progress.get((path, fh), None): + logger.debug("Matched read to an in-progress playlist addition.") + _, _, b = pap + return b[offset : offset + length] + os.lseek(fh, offset, os.SEEK_SET) return os.read(fh, length) def write(self, path: str, data: bytes, offset: int, fh: int) -> int: logger.debug(f"Received write for {path=} {data=} {offset=} {fh=}") + + if pap := self.playlist_additions_in_progress.get((path, fh), None): + logger.debug("Matched write to an in-progress playlist addition.") + playlist, ext, b = pap + self.playlist_additions_in_progress[(path, fh)] = (playlist, ext, b[:offset] + data) + return len(data) + os.lseek(fh, offset, os.SEEK_SET) return os.write(fh, data) def truncate(self, path: str, length: int, fh: int | None = None) -> None: logger.debug(f"Received truncate for {path=} {length=} {fh=}") + if fh: + if pap := self.playlist_additions_in_progress.get((path, fh), None): + logger.debug("Matched truncate to an in-progress playlist addition.") + playlist, ext, b = pap + self.playlist_additions_in_progress[(path, fh)] = (playlist, ext, b[:length]) + return return os.ftruncate(fh, length) p = parse_virtual_path(path) @@ -397,6 +479,27 @@ def truncate(self, path: str, length: int, fh: int | None = None) -> None: def release(self, path: str, fh: int) -> None: logger.debug(f"Received release for {path=} {fh=}") + if pap := self.playlist_additions_in_progress.get((path, fh), None): + logger.debug("Matched release to an in-progress playlist addition.") + playlist, ext, b = pap + if not b: + logger.debug("Aborting playlist addition release: no bytes to write.") + return + with tempfile.TemporaryDirectory() as tmpdir: + audiopath = Path(tmpdir) / f"f{ext}" + with audiopath.open("wb") as fp: + fp.write(b) + audiofile = AudioFile.from_file(audiopath) + track_id = audiofile.id + if not track_id: + logger.warning( + "Failed to parse track_id from file in playlist addition operation " + f"sequence: {path} {audiofile}" + ) + return + add_track_to_playlist(self.config, playlist, track_id) + del self.playlist_additions_in_progress[(path, fh)] + return os.close(fh) def mkdir(self, path: str, mode: int) -> None: @@ -532,19 +635,21 @@ def rename(self, old: str, new: str) -> None: # - ftruncate # - fgetattr # - lock + # - ioctl # - utimens # # Dummy implementations below: + def create(self, path: str, mode: int) -> int: + logger.debug(f"Received create for {path=} {mode=}") + return self.open(path, os.O_CREAT | os.O_WRONLY) + def chmod(self, *_, **__) -> None: # type: ignore pass def chown(self, *_, **__) -> None: # type: ignore pass - def create(self, *_, **__) -> None: # type: ignore - raise fuse.FuseOSError(errno.ENOTSUP) - @dataclass class ParsedPath: diff --git a/rose/virtualfs_test.py b/rose/virtualfs_test.py index d08d124..1b7479a 100644 --- a/rose/virtualfs_test.py +++ b/rose/virtualfs_test.py @@ -147,11 +147,18 @@ def test_virtual_filesystem_collage_actions(config: Config) -> None: assert not (src / "!collages" / "New Jeans.toml").exists() -@pytest.mark.usefixtures("seeded_cache") -def test_virtual_filesystem_playlist_actions(config: Config) -> None: +def test_virtual_filesystem_playlist_actions( + config: Config, + source_dir: Path, # noqa: ARG001 +) -> None: root = config.fuse_mount_dir src = config.music_source_dir + release_dir = ( + root / "1. Releases" / "{NEW} BLACKPINK - 1990. I Love Blackpink [K-Pop;Pop] {A Cool Label}" + ) + filename = "01. BLACKPINK - Track 1.m4a" + with startfs(config): # Create playlist. (root / "8. Playlists" / "New Tee").mkdir(parents=True) @@ -160,19 +167,16 @@ def test_virtual_filesystem_playlist_actions(config: Config) -> None: (root / "8. Playlists" / "New Tee").rename(root / "8. Playlists" / "New Jeans") assert (src / "!playlists" / "New Jeans.toml").is_file() assert not (src / "!playlists" / "New Tee.toml").exists() - # TODO: To implement. - # # Add track to playlist. - # shutil.copytree( - # root / "1. Releases" / "r1" / "01.m4a", root / "8. Playlists" / "New Jeans" / "01.m4a" - # ) - # assert (root / "8. Playlists" / "New Jeans" / "1. 01.m4a").is_file() - # with (src / "!playlists" / "New Jeans.toml").open("r") as fp: - # assert "01.m4a" in fp.read() - # # Delete release from playlist. - # (root / "8. Playlists" / "New Jeans" / "1. 01.m4a").unlink() - # assert not (root / "8. Playlists" / "New Jeans" / "1. 01.m4a").exists() - # with (src / "!playlists" / "New Jeans.toml").open("r") as fp: - # assert "01.m4a" not in fp.read() + # Add track to playlist. + shutil.copyfile(release_dir / filename, root / "8. Playlists" / "New Jeans" / filename) + assert (root / "8. Playlists" / "New Jeans" / "1. BLACKPINK - Track 1.m4a").is_file() + with (src / "!playlists" / "New Jeans.toml").open("r") as fp: + assert "BLACKPINK - Track 1.m4a" in fp.read() + # Delete track from playlist. + (root / "8. Playlists" / "New Jeans" / "1. BLACKPINK - Track 1.m4a").unlink() + assert not (root / "8. Playlists" / "New Jeans" / "1. BLACKPINK - Track 1.m4a").exists() + with (src / "!playlists" / "New Jeans.toml").open("r") as fp: + assert "BLACKPINK - Track 1.m4a" not in fp.read() # Delete playlist. (root / "8. Playlists" / "New Jeans").rmdir() assert not (src / "!playlists" / "New Jeans.toml").exists()