Skip to content

Commit

Permalink
support adding file to playlist via cp
Browse files Browse the repository at this point in the history
  • Loading branch information
azuline committed Oct 25, 2023
1 parent 8363b90 commit 0f444ce
Show file tree
Hide file tree
Showing 3 changed files with 136 additions and 27 deletions.
6 changes: 3 additions & 3 deletions rose/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 114 additions & 9 deletions rose/virtualfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
import stat
import subprocess
import tempfile
import time
from collections.abc import Iterator
from dataclasses import dataclass
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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]:
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 19 additions & 15 deletions rose/virtualfs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down

0 comments on commit 0f444ce

Please sign in to comment.