diff --git a/docs/PLAYLISTS_COLLAGES.md b/docs/PLAYLISTS_COLLAGES.md index 74717b4..4b5e743 100644 --- a/docs/PLAYLISTS_COLLAGES.md +++ b/docs/PLAYLISTS_COLLAGES.md @@ -104,14 +104,14 @@ $ cd $fuse_mount_dir $ mkdir "7. Collages/Morning" -$ tree "7. Collages" +$ tree "7. Collages/" 1. Collages/ ├── Morning/... └── Road Trip/... $ mkdir "8. Playlists/Evening" -$ tree "8. Playlists" +$ tree "8. Playlists/" 2. Playlists/ ├── Evening/... └── Shower/... @@ -227,7 +227,7 @@ BLACKPINK - 2016. SQUARE TWO - Single [Dance-Pop;K-Pop] [18:20:53] INFO: Refreshing the read cache for 1 collages [18:20:53] INFO: Applying cache updates for collage Road Trip -$ tree "7. Collages/Road Trip" +$ tree "7. Collages/Road Trip/" 7. Collages/Road Trip/ ├── 1. LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP [Dance-Pop;Future Bass;K-Pop]/... └── 2. BLACKPINK - 2016. SQUARE TWO - Single [Dance-Pop;K-Pop]/... @@ -246,7 +246,7 @@ LOOΠΔ ODD EYE CIRCLE - Chaotic.opus [18:22:42] INFO: Refreshing the read cache for 1 playlists [18:22:42] INFO: Applying cache updates for playlist Shower -$ tree "8. Playlists/Shower" +$ tree "8. Playlists/Shower/" 8. Playlists/Shower/ ├── 1. BLACKPINK - PLAYING WITH FIRE.opus └── 2. LOOΠΔ ODD EYE CIRCLE - Chaotic.opus @@ -279,14 +279,14 @@ $ cd $fuse_mount_dir $ rmdir "7. Collages/Morning" -$ tree "7. Collages" -7. Collages +$ tree "7. Collages/" +7. Collages/ └── Road Trip/... $ rmdir "8. Playlists/Evening" -$ tree "8. Playlists" -8. Playlists +$ tree "8. Playlists/" +8. Playlists/ └── Shower/... ``` @@ -307,7 +307,7 @@ $ rose collages rename "Road Trip" "Long Flight" [18:29:08] INFO: Evicting cached collages that are not on disk [18:29:08] INFO: Evicted collage Road Trip from cache -$ tree "7. Collages" +$ tree "7. Collages/" 7. Collages/ └── Long Flight/... @@ -318,7 +318,7 @@ $ rose playlists rename "Shower" "Meal Prep" [18:30:17] INFO: Evicting cached playlists that are not on disk [18:30:17] INFO: Evicted playlist Shower from cache -$ tree "8. Playlists" +$ tree "8. Playlists/" 8. Playlists/ └── Meal Prep/... ``` @@ -330,13 +330,55 @@ $ cd $fuse_mount_dir $ mv "7. Collages/Road Trip/" "7. Collages/Long Flight" -$ tree "7. Collages" -7. Collages +$ tree "7. Collages/" +7. Collages/ └── Long Flight/... $ mv "8. Playlists/Shower" "8. Playlsits/Meal Prep" -$ tree "8. Playlists" -8. Playlists +$ tree "8. Playlists/" +8. Playlists/ └── Meal Prep/... ``` + +## Set Playlist Cover Art + +_This operation is playlist-only, as collages do not have their own cover art._ + +_The filename of the cover art in the virtual filesystem will always appear as +`cover.{ext}`, regardless of the cover art name in the source directory._ + +Command line: + +```bash +$ cd $fuse_mount_dir + +$ rose playlists set-cover "Shower" ./cover.jpg +[20:51:59] INFO: Set the cover of playlist Shower to cover.jpg +[20:51:59] INFO: Refreshing the read cache for 1 playlists +[20:51:59] INFO: Applying cache updates for playlist Shower + +$ tree "8. Playlists/Shower/" +8. Playlists/Shower/ +├── 1. BLACKPINK - PLAYING WITH FIRE.opus +├── 2. LOOΠΔ ODD EYE CIRCLE - Chaotic.opus +└── cover.jpg +``` + +Virtual filesystem: + +_The filename of the created file in the release directory must be one of the +valid cover art filenames. The valid cover art filenames are controlled by and +documented in [Configuration](./CONFIGURATION.md)._ + +```bash +$ cd $fuse_mount_dir + +$ mv ~/downloads/cover.jpg "8. Playlists/Shower/cover.jpg" + +$ tree "8. Playlists/Shower/" +8. Playlists/Shower/ +├── 1. BLACKPINK - PLAYING WITH FIRE.opus +├── 2. LOOΠΔ ODD EYE CIRCLE - Chaotic.opus +└── cover.jpg +``` diff --git a/docs/VIRTUAL_FILESYSTEM.md b/docs/VIRTUAL_FILESYSTEM.md index 4b7a3d5..01146b4 100644 --- a/docs/VIRTUAL_FILESYSTEM.md +++ b/docs/VIRTUAL_FILESYSTEM.md @@ -102,7 +102,7 @@ All command line commands accept releases in three formats: 3. The path to the release in the virtual filesystem. The virtual filesystem must be mounted for this format to work. -## Toggle NEW-ness +## Toggle Release NEW-ness Command line: @@ -152,6 +152,52 @@ $ tree "2. Releases - New/" └── {NEW} LOOΠΔ - 2017. Kim Lip - Single [Contemporary R&B;Dance-Pop;K-Pop]/... ``` +## Set Release Cover Art + +_The filename of the cover art in the virtual filesystem will always appear as +`cover.{ext}`, regardless of the cover art name in the source directory._ + +Command line: + +```bash +$ cd $fuse_mount_dir + +$ rose releases set-cover "LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP [Dance-Pop;Future Bass;K-Pop]" ./cover.jpg +[20:43:50] INFO: Set the cover of release LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match to cover.jpg +[20:43:50] INFO: Refreshing the read cache for 1 releases +[20:43:50] INFO: Applying cache updates for release LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match + +$ tree "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP [Dance-Pop;Future Bass;K-Pop]/" +1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP [K-Pop]/ +├── 01. LOOΠΔ ODD EYE CIRCLE - ODD.opus +├── 02. LOOΠΔ ODD EYE CIRCLE - Girl Front.opus +├── 03. LOOΠΔ ODD EYE CIRCLE - LOONATIC.opus +├── 04. LOOΠΔ ODD EYE CIRCLE - Chaotic.opus +├── 05. LOOΠΔ ODD EYE CIRCLE - Starlight.opus +└── cover.jpg +``` + +Virtual filesystem: + +_The filename of the created file in the release directory must be one of the +valid cover art filenames. The valid cover art filenames are controlled by and +documented in [Configuration](./CONFIGURATION.md)._ + +```bash +$ cd $fuse_mount_dir + +$ mv ~/downloads/cover.jpg "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP [Dance-Pop;Future Bass;K-Pop]/cover.jpg" + +$ tree "1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP [Dance-Pop;Future Bass;K-Pop]/" +1. Releases/LOOΠΔ ODD EYE CIRCLE - 2017. Mix & Match - EP [K-Pop]/ +├── 01. LOOΠΔ ODD EYE CIRCLE - ODD.opus +├── 02. LOOΠΔ ODD EYE CIRCLE - Girl Front.opus +├── 03. LOOΠΔ ODD EYE CIRCLE - LOONATIC.opus +├── 04. LOOΠΔ ODD EYE CIRCLE - Chaotic.opus +├── 05. LOOΠΔ ODD EYE CIRCLE - Starlight.opus +└── cover.jpg +``` + ## Delete a Release _Deletion will move the release into the trashbin, following the diff --git a/rose/cache.py b/rose/cache.py index b9f10a3..222b469 100644 --- a/rose/cache.py +++ b/rose/cache.py @@ -48,7 +48,7 @@ from rose.artiststr import format_artist_string from rose.common import VERSION from rose.config import Config -from rose.tagger import SUPPORTED_EXTENSIONS, AudioFile +from rose.tagger import SUPPORTED_AUDIO_EXTENSIONS, AudioFile logger = logging.getLogger(__name__) @@ -572,7 +572,9 @@ def _update_cache_for_releases_executor( try: first_audio_file = Path( next( - f for f in files if any(f.lower().endswith(ext) for ext in SUPPORTED_EXTENSIONS) + f + for f in files + if any(f.lower().endswith(ext) for ext in SUPPORTED_AUDIO_EXTENSIONS) ) ) except StopIteration: @@ -727,7 +729,9 @@ def _update_cache_for_releases_executor( # tags. pulled_release_tags = False for f in files: - if not any(os.path.basename(f).lower().endswith(ext) for ext in SUPPORTED_EXTENSIONS): + if not any( + os.path.basename(f).lower().endswith(ext) for ext in SUPPORTED_AUDIO_EXTENSIONS + ): continue cached_track = cached_tracks.get(f, None) diff --git a/rose/cli.py b/rose/cli.py index 834cc98..d641282 100644 --- a/rose/cli.py +++ b/rose/cli.py @@ -36,8 +36,15 @@ edit_playlist_in_editor, remove_track_from_playlist, rename_playlist, + set_playlist_cover_art, +) +from rose.releases import ( + delete_release, + dump_releases, + edit_release, + set_release_cover_art, + toggle_release_new, ) -from rose.releases import delete_release, dump_releases, edit_release, toggle_release_new from rose.virtualfs import VirtualPath, mount_virtualfs, unmount_virtualfs from rose.watcher import start_watchdog @@ -186,6 +193,19 @@ def toggle_new(ctx: Context, release: str) -> None: toggle_release_new(ctx.config, release) +@releases.command() +@click.argument("release", type=str, nargs=1) +@click.argument("cover", type=click.Path(path_type=Path), nargs=1) +@click.pass_obj +def set_cover(ctx: Context, release: str, cover: Path) -> None: + """ + Set the cover art of a release. For the release argument, accepts a release UUID, virtual + directory name, or virtual filesystem path. For the cover argument, accept a path to the image. + """ + release = parse_release_from_potential_path(ctx.config, release) + set_release_cover_art(ctx.config, release, cover) + + @releases.command(name="delete") @click.argument("release", type=str, nargs=1) @click.pass_obj @@ -338,6 +358,17 @@ def print3(ctx: Context) -> None: print(dump_playlists(ctx.config)) +@playlists.command(name="set-cover") +@click.argument("playlist", type=str, nargs=1) +@click.argument("cover", type=click.Path(path_type=Path), nargs=1) +@click.pass_obj +def set_cover2(ctx: Context, playlist: str, cover: Path) -> None: + """ + Set the cover art of a playlist. Accepts a playlist name and a path to an image. + """ + set_playlist_cover_art(ctx.config, playlist, cover) + + def parse_release_from_potential_path(c: Config, r: str) -> str: """ Support paths from the virtual filesystem as valid releases. By default, we accept virtual diff --git a/rose/common.py b/rose/common.py index a7b0b86..0191a2a 100644 --- a/rose/common.py +++ b/rose/common.py @@ -14,6 +14,10 @@ class RoseError(Exception): pass +class InvalidCoverArtFileError(RoseError): + pass + + def valid_uuid(x: str) -> bool: try: uuid.UUID(x) diff --git a/rose/playlists.py b/rose/playlists.py index dc504e7..f26e573 100644 --- a/rose/playlists.py +++ b/rose/playlists.py @@ -4,6 +4,7 @@ import json import logging +import shutil from collections import Counter from pathlib import Path from typing import Any @@ -22,7 +23,7 @@ update_cache_evict_nonexistent_playlists, update_cache_for_playlists, ) -from rose.common import RoseError +from rose.common import InvalidCoverArtFileError, RoseError from rose.config import Config logger = logging.getLogger(__name__) @@ -202,5 +203,29 @@ def edit_playlist_in_editor(c: Config, playlist_name: str) -> None: update_cache_for_playlists(c, [playlist_name], force=True) +def set_playlist_cover_art(c: Config, playlist_name: str, new_cover_art_path: Path) -> None: + """ + This function removes all potential cover arts for the playlist, and then copies the file + file located at the passed in path to be the playlist's art file. + """ + suffix = new_cover_art_path.suffix.lower() + if suffix[1:] not in c.valid_art_exts: + raise InvalidCoverArtFileError( + f"File {new_cover_art_path.name}'s extension is not supported for cover images: " + "To change this, please read the configuration documentation" + ) + + path = playlist_path(c, playlist_name) + if not path.exists(): + raise PlaylistDoesNotExistError(f"Playlist {playlist_name} does not exist") + for f in (c.music_source_dir / "!playlists").iterdir(): + if f.stem == playlist_name and f.suffix[1:].lower() in c.valid_art_exts: + logger.debug(f"Deleting existing cover art {f.name} in playlists") + f.unlink() + shutil.copyfile(new_cover_art_path, path.with_suffix(suffix)) + logger.info(f"Set the cover of playlist {playlist_name} to {new_cover_art_path.name}") + update_cache_for_playlists(c, [playlist_name]) + + def playlist_path(c: Config, name: str) -> Path: return c.music_source_dir / "!playlists" / f"{name}.toml" diff --git a/rose/playlists_test.py b/rose/playlists_test.py index 1702eb1..165a89a 100644 --- a/rose/playlists_test.py +++ b/rose/playlists_test.py @@ -5,7 +5,7 @@ import pytest import tomllib -from conftest import TEST_RELEASE_1 +from conftest import TEST_PLAYLIST_1, TEST_RELEASE_1 from rose.cache import connect, update_cache from rose.config import Config from rose.playlists import ( @@ -16,6 +16,7 @@ edit_playlist_in_editor, remove_track_from_playlist, rename_playlist, + set_playlist_cover_art, ) @@ -234,3 +235,26 @@ def test_playlist_handle_missing_track(config: Config, source_dir: Path) -> None with connect(config) as conn: cursor = conn.execute("SELECT EXISTS(SELECT * FROM playlists WHERE name = 'You & Me')") assert not cursor.fetchone()[0] + + +def test_set_playlist_cover_art(isolated_dir: Path, config: Config) -> None: + imagepath = isolated_dir / "folder.png" + with imagepath.open("w") as fp: + fp.write("lalala") + + playlists_dir = config.music_source_dir / "!playlists" + shutil.copytree(TEST_PLAYLIST_1, playlists_dir) + (playlists_dir / "Turtle Rabbit.toml").touch() + (playlists_dir / "Turtle Rabbit.jpg").touch() + (playlists_dir / "Lala Lisa.txt").touch() + update_cache(config) + + set_playlist_cover_art(config, "Lala Lisa", imagepath) + assert (playlists_dir / "Lala Lisa.png").is_file() + assert not (playlists_dir / "Lala Lisa.jpg").exists() + assert (playlists_dir / "Lala Lisa.txt").is_file() + assert len(list(playlists_dir.iterdir())) == 5 + + with connect(config) as conn: + cursor = conn.execute("SELECT cover_path FROM playlists WHERE name = 'Lala Lisa'") + assert Path(cursor.fetchone()["cover_path"]) == playlists_dir / "Lala Lisa.png" diff --git a/rose/releases.py b/rose/releases.py index fa3b33b..270b9fb 100644 --- a/rose/releases.py +++ b/rose/releases.py @@ -6,6 +6,7 @@ import json import logging +import shutil from dataclasses import asdict, dataclass from pathlib import Path from typing import Any @@ -31,7 +32,7 @@ update_cache_for_collages, update_cache_for_releases, ) -from rose.common import RoseError, valid_uuid +from rose.common import InvalidCoverArtFileError, RoseError, valid_uuid from rose.config import Config from rose.tagger import AudioFile @@ -96,6 +97,36 @@ def toggle_release_new(c: Config, release_id_or_virtual_dirname: str) -> None: logger.critical(f"Failed to find .rose.toml in {source_path}") +def set_release_cover_art( + c: Config, + release_id_or_virtual_dirname: str, + new_cover_art_path: Path, +) -> None: + """ + This function removes all potential cover arts in the release source directory and copies the + file located at the passed in path to `cover.{ext}` in the release source directory. + """ + suffix = new_cover_art_path.suffix.lower() + if suffix[1:] not in c.valid_art_exts: + raise InvalidCoverArtFileError( + f"File {new_cover_art_path.name}'s extension is not supported for cover images: " + "To change this, please read the configuration documentation" + ) + + 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 f.name.lower() in c.valid_cover_arts: + logger.debug(f"Deleting existing cover art {f.name} in {release_dirname}") + send2trash(f) + shutil.copyfile(new_cover_art_path, source_path / f"cover{new_cover_art_path.suffix}") + logger.info(f"Set the cover of release {release_dirname} to {new_cover_art_path.name}") + update_cache_for_releases(c, [source_path]) + + @dataclass class MetadataArtist: name: str diff --git a/rose/releases_test.py b/rose/releases_test.py index ebf1d15..95c041b 100644 --- a/rose/releases_test.py +++ b/rose/releases_test.py @@ -14,6 +14,7 @@ dump_releases, edit_release, resolve_release_ids, + set_release_cover_art, toggle_release_new, ) @@ -39,7 +40,7 @@ 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") + cursor = conn.execute("SELECT id FROM releases") release_id = cursor.fetchone()["id"] datafile = config.music_source_dir / TEST_RELEASE_1.name / f".rose.{release_id}.toml" @@ -62,6 +63,37 @@ def test_toggle_release_new(config: Config) -> None: assert cursor.fetchone()["virtual_dirname"].startswith("{NEW} ") +def test_set_release_cover_art(isolated_dir: Path, config: Config) -> None: + imagepath = isolated_dir / "folder.jpg" + with imagepath.open("w") as fp: + fp.write("lalala") + + release_dir = config.music_source_dir / TEST_RELEASE_1.name + shutil.copytree(TEST_RELEASE_1, release_dir) + old_image_1 = release_dir / "folder.png" + old_image_2 = release_dir / "cover.jpeg" + old_image_1.touch() + old_image_2.touch() + update_cache(config) + with connect(config) as conn: + cursor = conn.execute("SELECT id FROM releases") + release_id = cursor.fetchone()["id"] + + set_release_cover_art(config, release_id, imagepath) + cover_image_path = release_dir / "cover.jpg" + assert cover_image_path.is_file() + with cover_image_path.open("r") as fp: + assert fp.read() == "lalala" + assert not old_image_1.exists() + assert not old_image_2.exists() + # Assert no other files were touched. + assert len(list(release_dir.iterdir())) == 5 + + with connect(config) as conn: + cursor = conn.execute("SELECT cover_image_path FROM releases") + assert Path(cursor.fetchone()["cover_image_path"]) == cover_image_path + + def test_edit_release(monkeypatch: Any, config: Config, source_dir: Path) -> None: release_path = source_dir / TEST_RELEASE_1.name with connect(config) as conn: diff --git a/rose/tagger.py b/rose/tagger.py index 2892ee2..1e405f7 100644 --- a/rose/tagger.py +++ b/rose/tagger.py @@ -30,7 +30,7 @@ YEAR_REGEX = re.compile(r"\d{4}$") DATE_REGEX = re.compile(r"(\d{4})-\d{2}-\d{2}") -SUPPORTED_EXTENSIONS = [ +SUPPORTED_AUDIO_EXTENSIONS = [ ".mp3", ".m4a", ".ogg", @@ -96,7 +96,7 @@ class AudioFile: @classmethod def from_file(cls, p: Path) -> AudioFile: """Read the tags of an audio file on disk.""" - if not any(p.suffix.lower() == ext for ext in SUPPORTED_EXTENSIONS): + if not any(p.suffix.lower() == ext for ext in SUPPORTED_AUDIO_EXTENSIONS): raise UnsupportedFiletypeError(f"{p.suffix} not a supported filetype") m = mutagen.File(p) # type: ignore if isinstance(m, mutagen.mp3.MP3): diff --git a/rose/virtualfs.py b/rose/virtualfs.py index 01c20fd..e6ec8ac 100644 --- a/rose/virtualfs.py +++ b/rose/virtualfs.py @@ -81,9 +81,15 @@ delete_playlist, remove_track_from_playlist, rename_playlist, + set_playlist_cover_art, ) -from rose.releases import ReleaseDoesNotExistError, delete_release, toggle_release_new -from rose.tagger import AudioFile +from rose.releases import ( + ReleaseDoesNotExistError, + delete_release, + set_release_cover_art, + toggle_release_new, +) +from rose.tagger import SUPPORTED_AUDIO_EXTENSIONS, AudioFile logger = logging.getLogger(__name__) @@ -376,31 +382,40 @@ def unwrap_host(self, rose_fh: int) -> int: re.compile(r"^\d+\. "), ] +FileCreationSpecialOp = Literal["add-track-to-playlist", "new-cover-art"] + class RoseLogicalCore: def __init__(self, config: Config, fhandler: FileHandleManager): self.config = config self.fhandler = fhandler 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`). + # This map stores the state for "file creation" operations. We currently have two file + # creation operations: + # + # 1. Add Track to Playlist: Because track filenames are not globally unique, the best way to + # figure out the track ID is to record the data written, and then parse the written bytes + # to find the track ID. + # 2. New Cover Art: When replacing the cover art of a release or playlist, the new cover art + # may have a different "filename" from the virtual `cover.{ext}` filename. We accept any + # of the supported filenames as configured by the user. When a new file matching the + # cover art filenames is written, it replaces the existing cover art. # # 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. + # 1. Upon file open, if the syscall matches one of the supported file creation operations, + # store the file descriptor in this map instead. # 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. + # bytes-to-write and store them in the map. + # 3. On release, process the written bytes and execute the real operation against the music + # library. # - # The state is a mapping of fh -> (playlist_name, ext, bytes). - self.playlist_additions_in_progress: dict[int, tuple[str, str, bytearray]] = {} + # The state is a mapping of fh -> (operation, identifier, ext, bytes). Identifier is typed + # based on the operation, and is used to identify the playlist/release being modified. + self.file_creation_special_ops: dict[ + int, tuple[FileCreationSpecialOp, Any, str, bytearray] + ] = {} # We want to trigger a cache update whenever we notice that a file has been updated through # the virtual filesystem. To do this, we insert the file handle and release ID on open, and # then trigger the cache update on release. We use this variable to transport that state @@ -764,30 +779,67 @@ def open(self, p: VirtualPath, flags: int) -> int: if p.release and p.file and (rdata := get_release(self.config, p.release)): release, tracks = rdata - if release.cover_image_path and p.file == release.cover_image_path.name: + # If the file is a music file, handle it as a music file. + pf = Path(p.file) + if pf.suffix.lower() in SUPPORTED_AUDIO_EXTENSIONS: + for track in tracks: + if track.virtual_filename == p.file: + fh = self.fhandler.wrap_host(os.open(str(track.source_path), flags)) + if flags & os.O_WRONLY == os.O_WRONLY or flags & os.O_RDWR == os.O_RDWR: + self.update_release_on_fh_close[fh] = track.release_id + return fh + # If the file matches the current cover image, then simply pass it through. + if release.cover_image_path and p.file == f"cover{release.cover_image_path.suffix}": return self.fhandler.wrap_host(os.open(str(release.cover_image_path), flags)) - for track in tracks: - if track.virtual_filename == p.file: - fh = self.fhandler.wrap_host(os.open(str(track.source_path), flags)) - if flags & os.O_WRONLY == os.O_WRONLY or flags & os.O_RDWR == os.O_RDWR: - self.update_release_on_fh_close[fh] = track.release_id - return fh + # Otherwise, if we are writing a brand new cover image, initiate the "new-cover-art" + # sequence. + if p.file.lower() in self.config.valid_cover_arts and flags & os.O_CREAT == os.O_CREAT: + fh = self.fhandler.next() + logger.debug( + f"LOGICAL: Begin new cover art sequence for release " + f"{release.virtual_dirname=}, {p.file=}, and {fh=}" + ) + self.file_creation_special_ops[fh] = ( + "new-cover-art", + ("release", release.id), + pf.suffix, + bytearray(), + ) + return fh raise llfuse.FUSEError(err) if p.playlist and p.file: try: playlist, tracks = get_playlist(self.config, p.playlist) # type: ignore except TypeError as e: raise llfuse.FUSEError(errno.ENOENT) from e - # 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: + # If we are trying to create an audio file in the playlist, enter the + # "add-track-to-playlist" operation sequence. See the __init__ for more details. + pf = Path(p.file) + if pf.suffix.lower() in SUPPORTED_AUDIO_EXTENSIONS and flags & os.O_CREAT == os.O_CREAT: fh = self.fhandler.next() logger.debug( - f"LOGICAL: Begin playlist addition operation sequence for {p.file=} and {fh=}" + f"LOGICAL: Begin playlist addition operation sequence for " + f"{playlist.name=}, {p.file=}, and {fh=}" ) - self.playlist_additions_in_progress[fh] = ( + self.file_creation_special_ops[fh] = ( + "add-track-to-playlist", p.playlist, - Path(p.file).suffix, + pf.suffix, + bytearray(), + ) + return fh + # If we are trying to create a cover image in the playlist, enter the "new-cover-art" + # sequence for the playlist. + if p.file.lower() in self.config.valid_cover_arts and flags & os.O_CREAT == os.O_CREAT: + fh = self.fhandler.next() + logger.debug( + f"LOGICAL: Begin new cover art sequence for playlist" + f"{playlist.name=}, {p.file=}, and {fh=}" + ) + self.file_creation_special_ops[fh] = ( + "new-cover-art", + ("playlist", p.playlist), + pf.suffix, bytearray(), ) return fh @@ -807,9 +859,9 @@ def open(self, p: VirtualPath, flags: int) -> int: def read(self, fh: int, offset: int, length: int) -> bytes: logger.debug(f"LOGICAL: Received read for {fh=} {offset=} {length=}") - if pap := self.playlist_additions_in_progress.get(fh, None): - logger.debug("LOGICAL: Matched read to an in-progress playlist addition") - _, _, b = pap + if sop := self.file_creation_special_ops.get(fh, None): + logger.debug("LOGICAL: Matched read to a file creation special op") + _, _, _, b = sop return b[offset : offset + length] fh = self.fhandler.unwrap_host(fh) os.lseek(fh, offset, os.SEEK_SET) @@ -817,9 +869,9 @@ def read(self, fh: int, offset: int, length: int) -> bytes: def write(self, fh: int, offset: int, data: bytes) -> int: logger.debug(f"LOGICAL: Received write for {fh=} {offset=} {len(data)=}") - if pap := self.playlist_additions_in_progress.get(fh, None): - logger.debug("LOGICAL: Matched write to an in-progress playlist addition") - _, _, b = pap + if sop := self.file_creation_special_ops.get(fh, None): + logger.debug("LOGICAL: Matched write to a file creation special op") + _, _, _, b = sop del b[offset:] b.extend(data) return len(data) @@ -829,27 +881,55 @@ def write(self, fh: int, offset: int, data: bytes) -> int: def release(self, fh: int) -> None: logger.debug(f"LOGICAL: Received release for {fh=}") - if pap := self.playlist_additions_in_progress.get(fh, None): - logger.debug("Matched release to an in-progress playlist addition") - playlist, ext, b = pap + if sop := self.file_creation_special_ops.get(fh, None): + logger.debug("LOGICAL: Matched release to a file creation special op") + operation, ident, ext, b = sop if not b: - logger.debug("LOGICAL: Aborting playlist addition release: no bytes to write") + logger.debug("LOGICAL: Aborting file creation special oprelease: 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( - "LOGICAL: Failed to parse track_id from file in playlist addition operation " - f"sequence: {track_id=} {fh=} {playlist=} {audiofile}" - ) + if operation == "add-track-to-playlist": + logger.debug("LOGICAL: Narrowed file creation special op to add track to playlist") + playlist = ident + 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( + "LOGICAL: Failed to parse track_id from file in playlist addition " + f"operation sequence: {track_id=} {fh=} {playlist=} {audiofile}" + ) + return + add_track_to_playlist(self.config, playlist, track_id) + del self.file_creation_special_ops[fh] return - add_track_to_playlist(self.config, playlist, track_id) - del self.playlist_additions_in_progress[fh] - return + if operation == "new-cover-art": + entity_type, entity_id = ident + if entity_type == "release": + logger.debug( + "LOGICAL: Narrowed file creation special op to write release cover art" + ) + with tempfile.TemporaryDirectory() as tmpdir: + imagepath = Path(tmpdir) / f"f{ext}" + with imagepath.open("wb") as fp: + fp.write(b) + set_release_cover_art(self.config, entity_id, imagepath) + del self.file_creation_special_ops[fh] + return + if entity_type == "playlist": + logger.debug( + "LOGICAL: Narrowed file creation special op to write playlist cover art" + ) + with tempfile.TemporaryDirectory() as tmpdir: + imagepath = Path(tmpdir) / f"f{ext}" + with imagepath.open("wb") as fp: + fp.write(b) + set_playlist_cover_art(self.config, entity_id, imagepath) + del self.file_creation_special_ops[fh] + return + raise RoseError(f"Impossible: unknown file creation special op: {operation=} {ident=}") if release_id := self.update_release_on_fh_close.get(fh, None): logger.debug( f"LOGICAL: Triggering cache update for release {release_id} after release syscall" diff --git a/rose/virtualfs_test.py b/rose/virtualfs_test.py index 6ddebcb..768a447 100644 --- a/rose/virtualfs_test.py +++ b/rose/virtualfs_test.py @@ -237,6 +237,68 @@ def test_virtual_filesystem_playlist_actions( assert not (src / "!playlists" / "New Jeans.toml").exists() +def test_virtual_filesystem_set_release_cover_art( + config: Config, + source_dir: Path, # noqa: ARG001 +) -> None: + root = config.fuse_mount_dir + release_dir = root / "1. Releases" / "{NEW} BLACKPINK - 1990. I Love Blackpink [K-Pop;Pop]" + with start_virtual_fs(config): + assert not (release_dir / "cover.jpg").is_file() + # First write. + with (release_dir / "folder.jpg").open("w") as fp: + fp.write("hi") + assert (release_dir / "cover.jpg").is_file() + with (release_dir / "cover.jpg").open("r") as fp: + assert fp.read() == "hi" + + # Second write to same filename. + with (release_dir / "cover.jpg").open("w") as fp: + fp.write("hi") + with (release_dir / "cover.jpg").open("r") as fp: + assert fp.read() == "hi" + + # Third write to different filename. + with (release_dir / "front.png").open("w") as fp: + fp.write("hi") + assert (release_dir / "cover.png").is_file() + with (release_dir / "cover.png").open("r") as fp: + assert fp.read() == "hi" + # Because of ghost writes, getattr succeeds, so we shouldn't check exists(). + assert "cover.jpg" not in [f.name for f in release_dir.iterdir()] + + +def test_virtual_filesystem_set_playlist_cover_art( + config: Config, + source_dir: Path, # noqa: ARG001 +) -> None: + root = config.fuse_mount_dir + playlist_dir = root / "8. Playlists" / "Lala Lisa" + with start_virtual_fs(config): + assert (playlist_dir / "cover.jpg").is_file() + # First write. + with (playlist_dir / "folder.jpg").open("w") as fp: + fp.write("hi") + assert (playlist_dir / "cover.jpg").is_file() + with (playlist_dir / "cover.jpg").open("r") as fp: + assert fp.read() == "hi" + + # Second write to same filename. + with (playlist_dir / "cover.jpg").open("w") as fp: + fp.write("hi") + with (playlist_dir / "cover.jpg").open("r") as fp: + assert fp.read() == "hi" + + # Third write to different filename. + with (playlist_dir / "front.png").open("w") as fp: + fp.write("hi") + assert (playlist_dir / "cover.png").is_file() + with (playlist_dir / "cover.png").open("r") as fp: + assert fp.read() == "hi" + # Because of ghost writes, getattr succeeds, so we shouldn't check exists(). + assert "cover.jpg" not in [f.name for f in playlist_dir.iterdir()] + + def test_virtual_filesystem_delete_release(config: Config, source_dir: Path) -> None: dirname = "NewJeans - 1990. I Love NewJeans [K-Pop;R&B]" root = config.fuse_mount_dir