diff --git a/conftest.py b/conftest.py index 9b46e43..d932091 100644 --- a/conftest.py +++ b/conftest.py @@ -121,11 +121,11 @@ def seeded_cache(config: Config) -> None: , ('r2' , 'Native State', 'Native State'); INSERT INTO tracks - (id , source_path , source_mtime, virtual_filename, title , release_id, track_number, disc_number, duration_seconds, formatted_artists) -VALUES ('t1', '{musicpaths[0]}', '999' , '01.m4a' , 'Track 1', 'r1' , '01' , '01' , 120 , 'Techno Man;Bass Man') - , ('t2', '{musicpaths[1]}', '999' , '02.m4a' , 'Track 2', 'r1' , '02' , '01' , 240 , 'Techno Man;Bass Man') - , ('t3', '{musicpaths[2]}', '999' , '01.m4a' , 'Track 1', 'r2' , '01' , '01' , 120 , 'Violin Woman feat. Conductor Woman') - , ('t4', '{musicpaths[3]}', '999' , '01.m4a' , 'Track 1', 'r3' , '01' , '01' , 120 , ''); + (id , source_path , source_mtime, virtual_filename, formatted_release_position, title , release_id, track_number, disc_number, duration_seconds, formatted_artists) +VALUES ('t1', '{musicpaths[0]}', '999' , '01.m4a' , '01' , 'Track 1', 'r1' , '01' , '01' , 120 , 'Techno Man;Bass Man') + , ('t2', '{musicpaths[1]}', '999' , '02.m4a' , '02' , 'Track 2', 'r1' , '02' , '01' , 240 , 'Techno Man;Bass Man') + , ('t3', '{musicpaths[2]}', '999' , '01.m4a' , '01' , 'Track 1', 'r2' , '01' , '01' , 120 , 'Violin Woman feat. Conductor Woman') + , ('t4', '{musicpaths[3]}', '999' , '01.m4a' , '02' , 'Track 1', 'r3' , '01' , '01' , 120 , ''); INSERT INTO releases_artists (release_id, artist , artist_sanitized , role , alias) @@ -150,8 +150,8 @@ def seeded_cache(config: Config) -> None: INSERT INTO collages_releases (collage_name, release_id, position) -VALUES ('Rose Gold' , 'r1' , 0) - , ('Rose Gold' , 'r2' , 1); +VALUES ('Rose Gold' , 'r1' , 1) + , ('Rose Gold' , 'r2' , 2); INSERT INTO playlists (name , source_mtime) @@ -160,8 +160,8 @@ def seeded_cache(config: Config) -> None: INSERT INTO playlists_tracks (playlist_name, track_id, position) -VALUES ('Lala Lisa' , 't1' , 0) - , ('Lala Lisa' , 't3' , 1); +VALUES ('Lala Lisa' , 't1' , 1) + , ('Lala Lisa' , 't3' , 2); """ # noqa: E501 ) diff --git a/rose/__init__.py b/rose/__init__.py index 9411eb7..c047cb9 100644 --- a/rose/__init__.py +++ b/rose/__init__.py @@ -10,9 +10,13 @@ STATE_HOME.mkdir(parents=True, exist_ok=True) LOGFILE = STATE_HOME / "rose.log" +# Useful for debugging problems with the virtual FS, since pytest doesn't capture that debug logging +# output. +LOG_EVEN_THOUGH_WERE_IN_TEST = True + # Add a logging handler for stdout unless we are testing. Pytest # captures logging output on its own. -if "pytest" not in sys.modules: # pragma: no cover +if "pytest" not in sys.modules or LOG_EVEN_THOUGH_WERE_IN_TEST: # pragma: no cover stream_formatter = logging.Formatter( "[%(asctime)s] %(levelname)s: %(message)s", datefmt="%H:%M:%S", diff --git a/rose/cache.py b/rose/cache.py index 9e5d42c..293cd1f 100644 --- a/rose/cache.py +++ b/rose/cache.py @@ -169,6 +169,7 @@ class CachedTrack: release_id: str track_number: str disc_number: str + formatted_release_position: str duration_seconds: int artists: list[CachedArtist] @@ -453,6 +454,7 @@ def _update_cache_for_releases_executor( , t.release_id , t.track_number , t.disc_number + , t.formatted_release_position , t.duration_seconds , t.formatted_artists , COALESCE(a.names, '') AS artist_names @@ -482,6 +484,7 @@ def _update_cache_for_releases_executor( release_id=row["release_id"], track_number=row["track_number"], disc_number=row["disc_number"], + formatted_release_position=row["formatted_release_position"], duration_seconds=row["duration_seconds"], artists=track_artists, formatted_artists=row["formatted_artists"], @@ -773,8 +776,12 @@ def _update_cache_for_releases_executor( virtual_filename="", title=tags.title or "Unknown Title", release_id=release.id, - track_number=tags.track_number or "1", - disc_number=tags.disc_number or "1", + # Remove `.` here because we use `.` to parse out discno/trackno in the virtual + # filesystem. It should almost never happen, but better to be safe. + track_number=(tags.track_number or "1").replace(".", ""), + disc_number=(tags.disc_number or "1").replace(".", ""), + # This is calculated with the virtual filename. + formatted_release_position="", duration_seconds=tags.duration_sec, artists=[], formatted_artists=format_artist_string(tags.artists, release.genres), @@ -788,8 +795,8 @@ def _update_cache_for_releases_executor( track.artists.append(CachedArtist(name=alias, role=role, alias=True)) track_ids_to_insert.add(track.id) - # Now calculate whether this release is multidisc, and then assign virtual_filenames for - # each track that lacks one. + # Now calculate whether this release is multidisc, and then assign virtual_filenames and + # formatted_release_positions for each track that lacks one. multidisc = len({t.disc_number for t in tracks}) > 1 if release.multidisc != multidisc: logger.debug(f"Release multidisc change detected for {source_path}, updating") @@ -798,11 +805,20 @@ def _update_cache_for_releases_executor( # Use this set to avoid name collisions. seen_track_names: set[str] = set() for i, t in enumerate(tracks): - virtual_filename = "" + formatted_release_position = "" if multidisc and t.disc_number: - virtual_filename += f"{t.disc_number:0>2}-" + formatted_release_position += f"{t.disc_number:0>2}-" if t.track_number: - virtual_filename += f"{t.track_number:0>2}. " + formatted_release_position += f"{t.track_number:0>2}" + if formatted_release_position != t.formatted_release_position: + logger.debug( + f"Track formatted release position change detected for {t.source_path}, " + "updating" + ) + tracks[i].formatted_release_position = formatted_release_position + track_ids_to_insert.add(t.id) + + virtual_filename = "" virtual_filename += f"{t.formatted_artists} - " virtual_filename += t.title or "Unknown Title" virtual_filename += t.source_path.suffix @@ -873,6 +889,7 @@ def _update_cache_for_releases_executor( track.release_id, track.track_number, track.disc_number, + track.formatted_release_position, track.duration_seconds, track.formatted_artists, ] @@ -989,20 +1006,22 @@ def _update_cache_for_releases_executor( , release_id , track_number , disc_number + , formatted_release_position , duration_seconds , formatted_artists ) - VALUES {",".join(["(?,?,?,?,?,?,?,?,?,?)"]*len(upd_track_args))} + VALUES {",".join(["(?,?,?,?,?,?,?,?,?,?,?)"]*len(upd_track_args))} ON CONFLICT (id) DO UPDATE SET - source_path = excluded.source_path - , source_mtime = excluded.source_mtime - , virtual_filename = excluded.virtual_filename - , title = excluded.title - , release_id = excluded.release_id - , track_number = excluded.track_number - , disc_number = excluded.disc_number - , duration_seconds = excluded.duration_seconds - , formatted_artists = excluded.formatted_artists + source_path = excluded.source_path + , source_mtime = excluded.source_mtime + , virtual_filename = excluded.virtual_filename + , title = excluded.title + , release_id = excluded.release_id + , track_number = excluded.track_number + , disc_number = excluded.disc_number + , formatted_release_position = excluded.formatted_release_position + , duration_seconds = excluded.duration_seconds + , formatted_artists = excluded.formatted_artists """, _flatten(upd_track_args), ) @@ -1095,7 +1114,12 @@ def update_cache_for_collages( release_ids=[], ) - source_mtime = str(f.stat().st_mtime) + try: + source_mtime = str(f.stat().st_mtime) + except FileNotFoundError: + # Collage was deleted... continue without doing anything. It will be cleaned up by + # the eviction function. + continue if source_mtime == cached_collage.source_mtime and not force: logger.debug(f"Collage cache hit (mtime) for {source_path}, reusing cached data") continue @@ -1256,7 +1280,12 @@ def update_cache_for_playlists( track_ids=[], ) - source_mtime = str(f.stat().st_mtime) + try: + source_mtime = str(f.stat().st_mtime) + except FileNotFoundError: + # Playlist was deleted... continue without doing anything. It will be cleaned up by + # the eviction function. + continue if source_mtime == cached_playlist.source_mtime and not force: logger.debug(f"playlist cache hit (mtime) for {source_path}, reusing cached data") continue @@ -1556,6 +1585,7 @@ def get_release( , t.release_id , t.track_number , t.disc_number + , t.formatted_release_position , t.duration_seconds , t.formatted_artists , COALESCE(a.names, '') AS artist_names @@ -1586,6 +1616,7 @@ def get_release( release_id=row["release_id"], track_number=row["track_number"], disc_number=row["disc_number"], + formatted_release_position=row["formatted_release_position"], duration_seconds=row["duration_seconds"], formatted_artists=row["formatted_artists"], artists=tartists, @@ -1670,12 +1701,12 @@ def list_playlists(c: Config) -> Iterator[str]: yield row["name"] -def list_playlist_tracks(c: Config, playlist_name: str) -> Iterator[tuple[int, str, Path]]: - """Returns tuples of (position, track_virtual_filename, track_source_path).""" +def list_playlist_tracks(c: Config, playlist_name: str) -> Iterator[tuple[int, str, str, Path]]: + """Returns tuples of (position, track_id, track_virtual_filename, track_source_path).""" with connect(c) as conn: cursor = conn.execute( """ - SELECT pt.position, t.virtual_filename, t.source_path + SELECT pt.position, t.id, t.virtual_filename, t.source_path FROM playlists_tracks pt JOIN tracks t ON t.id = pt.track_id WHERE pt.playlist_name = ? @@ -1684,7 +1715,7 @@ def list_playlist_tracks(c: Config, playlist_name: str) -> Iterator[tuple[int, s (playlist_name,), ) for row in cursor: - yield (row["position"], row["virtual_filename"], Path(row["source_path"])) + yield (row["position"], row["id"], row["virtual_filename"], Path(row["source_path"])) def list_collages(c: Config) -> Iterator[str]: diff --git a/rose/cache.sql b/rose/cache.sql index 8bbccec..33e1920 100644 --- a/rose/cache.sql +++ b/rose/cache.sql @@ -68,6 +68,10 @@ CREATE TABLE tracks ( release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, track_number TEXT NOT NULL, disc_number TEXT NOT NULL, + -- Formatted disc_number/track_number combination that prefixes the virtual_filename in the + -- release view. This can be derived on-the-fly, but doesn't hurt to compute it once and pull it + -- from the cache after. + formatted_release_position TEXT NOT NULL, duration_seconds INTEGER NOT NULL, -- This is its own state because ordering matters--we preserve the ordering in the tags. -- However, the one-to-many table does not have ordering. diff --git a/rose/cache_test.py b/rose/cache_test.py index 4b7690d..3defcbc 100644 --- a/rose/cache_test.py +++ b/rose/cache_test.py @@ -770,6 +770,7 @@ def test_get_release(config: Config) -> None: release_id="r1", track_number="01", disc_number="01", + formatted_release_position="01", duration_seconds=120, artists=[ CachedArtist(name="Bass Man", role="main", alias=False), @@ -786,6 +787,7 @@ def test_get_release(config: Config) -> None: release_id="r1", track_number="02", disc_number="01", + formatted_release_position="02", duration_seconds=240, artists=[ CachedArtist(name="Bass Man", role="main", alias=False), @@ -854,8 +856,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"), + (1, "r1", config.music_source_dir / "r1"), + (2, "r2", config.music_source_dir / "r2"), } releases = list(list_collage_releases(config, "Ruby Red")) assert releases == [] @@ -871,8 +873,8 @@ def test_list_playlists(config: Config) -> None: def test_list_playlist_tracks(config: Config) -> None: tracks = list(list_playlist_tracks(config, "Lala Lisa")) assert set(tracks) == { - (0, "01.m4a", config.music_source_dir / "r1" / "01.m4a"), - (1, "01.m4a", config.music_source_dir / "r2" / "01.m4a"), + (1, "t1", "01.m4a", config.music_source_dir / "r1" / "01.m4a"), + (2, "t3", "01.m4a", config.music_source_dir / "r2" / "01.m4a"), } tracks = list(list_playlist_tracks(config, "Turtle Rabbit")) assert tracks == [] diff --git a/rose/collages_test.py b/rose/collages_test.py index 6f131d4..4fcbc09 100644 --- a/rose/collages_test.py +++ b/rose/collages_test.py @@ -126,7 +126,7 @@ def test_rename_collage(config: Config, source_dir: Path) -> None: def test_dump_collages(config: Config) -> None: out = dump_collages(config) # fmt: off - assert out == '{"Rose Gold": [{"position": 0, "release": "r1"}, {"position": 1, "release": "r2"}], "Ruby Red": []}' # noqa: E501 + assert out == '{"Rose Gold": [{"position": 1, "release": "r1"}, {"position": 2, "release": "r2"}], "Ruby Red": []}' # noqa: E501 # fmt: on diff --git a/rose/playlists.py b/rose/playlists.py index d46d9dc..3dd8c41 100644 --- a/rose/playlists.py +++ b/rose/playlists.py @@ -121,8 +121,14 @@ def dump_playlists(c: Config) -> str: playlist_names = list(list_playlists(c)) for name in playlist_names: out[name] = [] - for pos, virtual_filename, _ in list_playlist_tracks(c, name): - out[name].append({"position": pos, "track": virtual_filename}) + for pos, track_id, virtual_filename, _ in list_playlist_tracks(c, name): + out[name].append( + { + "position": pos, + "track_id": track_id, + "track_filename": virtual_filename, + } + ) return json.dumps(out) diff --git a/rose/playlists_test.py b/rose/playlists_test.py index 5f8b8d0..748fb70 100644 --- a/rose/playlists_test.py +++ b/rose/playlists_test.py @@ -114,7 +114,7 @@ def test_rename_playlist(config: Config, source_dir: Path) -> None: def test_dump_playlists(config: Config) -> None: out = dump_playlists(config) # fmt: off - assert out == '{"Lala Lisa": [{"position": 0, "track": "01.m4a"}, {"position": 1, "track": "01.m4a"}], "Turtle Rabbit": []}' # noqa: E501 + assert out == '{"Lala Lisa": [{"position": 1, "track_id": "t1", "track_filename": "01.m4a"}, {"position": 2, "track_id": "t3", "track_filename": "01.m4a"}], "Turtle Rabbit": []}' # noqa: E501 # fmt: on diff --git a/rose/releases_test.py b/rose/releases_test.py index c907b1e..875857b 100644 --- a/rose/releases_test.py +++ b/rose/releases_test.py @@ -136,11 +136,12 @@ def test_edit_release(monkeypatch: Any, config: Config, source_dir: Path) -> Non id=track_ids[0], source_path=release_path / "01.m4a", source_mtime=tracks[0].source_mtime, - virtual_filename="01. BLACKPINK - I Do Like That.m4a", + virtual_filename="BLACKPINK - I Do Like That.m4a", title="I Do Like That", release_id=release_id, track_number="1", disc_number="1", + formatted_release_position="01", duration_seconds=2, artists=[ CachedArtist(name="BLACKPINK", role="main", alias=False), @@ -151,11 +152,12 @@ def test_edit_release(monkeypatch: Any, config: Config, source_dir: Path) -> Non id=track_ids[1], source_path=release_path / "02.m4a", source_mtime=tracks[1].source_mtime, - virtual_filename="02. JISOO - All Eyes On Me.m4a", + virtual_filename="JISOO - All Eyes On Me.m4a", title="All Eyes On Me", release_id=release_id, track_number="2", disc_number="1", + formatted_release_position="02", duration_seconds=2, artists=[ CachedArtist(name="JISOO", role="main", alias=False), diff --git a/rose/virtualfs.py b/rose/virtualfs.py index 054c0ed..2122f9a 100644 --- a/rose/virtualfs.py +++ b/rose/virtualfs.py @@ -25,7 +25,10 @@ list_collages, list_genres, list_labels, + list_playlist_tracks, + list_playlists, list_releases, + playlist_exists, release_exists, track_exists, ) @@ -37,6 +40,12 @@ rename_collage, ) from rose.config import Config +from rose.playlists import ( + create_playlist, + delete_playlist, + remove_track_from_playlist, + rename_playlist, +) from rose.releases import ReleaseDoesNotExistError, delete_release, toggle_release_new logger = logging.getLogger(__name__) @@ -146,6 +155,14 @@ def getattr(self, path: str, fh: int) -> dict[str, Any]: elif p.collage: if collage_exists(self.config, p.collage): return mkstat("dir") + elif p.playlist and p.file: + 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 mkstat("file", tp) + elif p.playlist: + if playlist_exists(self.config, p.playlist): + return mkstat("dir") elif p.view: return mkstat("dir") @@ -170,6 +187,7 @@ def readdir(self, path: str, _: int) -> Iterator[str]: "5. Genres", "6. Labels", "7. Collages", + "8. Playlists", ] elif p.release: cachedata = get_release(self.config, p.release) @@ -177,8 +195,9 @@ def readdir(self, path: str, _: int) -> Iterator[str]: raise fuse.FuseOSError(errno.ENOENT) from None release, tracks = cachedata for track in tracks: - yield track.virtual_filename - self.getattr_cache[path + "/" + track.virtual_filename] = ( + filename = f"{track.formatted_release_position}. {track.virtual_filename}" + yield filename + self.getattr_cache[path + "/" + filename] = ( time.time(), ("file", track.source_path), ) @@ -245,6 +264,18 @@ def readdir(self, path: str, _: int) -> Iterator[str]: for collage in list_collages(self.config): yield collage self.getattr_cache[path + "/" + collage] = (time.time(), ("dir",)) + elif p.view == "Playlists" and p.playlist: + ptracks = list(list_playlist_tracks(self.config, p.playlist)) + pad_size = max(len(str(r[0])) for r in ptracks) + for idx, __, virtual_filename, source_dir in ptracks: + v = f"{str(idx).zfill(pad_size)}. {virtual_filename}" + yield v + self.getattr_cache[path + "/" + v] = (time.time(), ("file", source_dir)) + elif p.view == "Playlists": + # Don't need to sanitize because the playlist names come from filenames. + for playlist in list_playlists(self.config): + yield playlist + self.getattr_cache[path + "/" + playlist] = (time.time(), ("dir",)) else: raise fuse.FuseOSError(errno.ENOENT) @@ -262,6 +293,13 @@ def open(self, path: str, flags: int) -> int: for track in tracks: if track.virtual_filename == p.file: return os.open(str(track.source_path), flags) + elif p.playlist and p.file: + 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) + # TODO: Cover + pass if flags & os.O_CREAT == os.O_CREAT: raise fuse.FuseOSError(errno.EACCES) @@ -306,9 +344,8 @@ def mkdir(self, path: str, mode: int) -> None: # Possible actions: # 1. Add a release to an existing collage. # 2. Create a new collage. - if p.view != "Collages" or (p.collage is None and p.release is None): - raise fuse.FuseOSError(errno.EACCES) - elif p.collage and p.release is None: + # 3. Create a new playlist. + if p.collage and p.release is None: create_collage(self.config, p.collage) elif p.collage and p.release: try: @@ -318,6 +355,8 @@ def mkdir(self, path: str, mode: int) -> None: f"Failed adding release {p.release} to collage {p.collage}: release not found." ) raise fuse.FuseOSError(errno.ENOENT) from e + elif p.playlist and p.file is None: + create_playlist(self.config, p.playlist) else: raise fuse.FuseOSError(errno.EACCES) @@ -327,8 +366,9 @@ def rmdir(self, path: str) -> None: logger.debug(f"Parsed rmdir path as {p}") # Possible actions: - # 1. Delete a release from an existing collage. - # 2. Delete a collage. + # 1. Delete a collage. + # 2. Delete a playlist. + # 3. Delete a release from an existing collage. if p.view == "Collages": if p.collage and p.release is None: delete_collage(self.config, p.collage) @@ -336,11 +376,41 @@ def rmdir(self, path: str) -> None: remove_release_from_collage(self.config, p.collage, p.release) else: raise fuse.FuseOSError(errno.EACCES) + elif p.view == "Playlists": + if p.playlist and p.file is None: + delete_playlist(self.config, p.playlist) + else: + raise fuse.FuseOSError(errno.EACCES) elif p.release is not None: delete_release(self.config, p.release) else: raise fuse.FuseOSError(errno.EACCES) + def unlink(self, path: str) -> None: + logger.debug(f"Received unlink for {path=}") + p = parse_virtual_path(path) + logger.debug(f"Parsed unlink path as {p}") + + # Possible actions: + # 1. Delete a playlist. + # 2. Delete a track from a playlist. + if p.view == "Playlists": + if p.playlist and p.file is None: + delete_playlist(self.config, p.playlist) + elif p.playlist and p.file: + if p.file_position: + for pos, track_id, virtual_filename, _ in list_playlist_tracks( + self.config, p.playlist + ): + if virtual_filename == p.file and pos == int(p.file_position): + remove_track_from_playlist(self.config, p.playlist, track_id) + return + raise fuse.FuseOSError(errno.EACCES) + else: + raise fuse.FuseOSError(errno.EACCES) + else: + raise fuse.FuseOSError(errno.EACCES) + def rename(self, old: str, new: str) -> None: logger.debug(f"Received rename for {old=} {new=}") op = parse_virtual_path(old) @@ -349,8 +419,10 @@ def rename(self, old: str, new: str) -> None: logger.debug(f"Parsed rename new path as {np}") # Possible actions: - # 1. Rename a collage. - # 2. Toggle a release's new status. + # 1. Toggle a release's new status. + # 2. Rename a collage. + # 3. Rename a playlist. + # TODO: Consider allowing renaming artist/genre/label here? if ( (op.release and np.release) and op.release.removeprefix("{NEW} ") == np.release.removeprefix("{NEW} ") @@ -369,14 +441,21 @@ def rename(self, old: str, new: str) -> None: rename_collage(self.config, op.collage, np.collage) else: raise fuse.FuseOSError(errno.EACCES) + elif op.view == "Playlists" and np.view == "Playlists": + if ( + (op.playlist and np.playlist) + and op.playlist != np.playlist + and (not op.file and not np.file) + ): + rename_playlist(self.config, op.playlist, np.playlist) + else: + raise fuse.FuseOSError(errno.EACCES) else: raise fuse.FuseOSError(errno.EACCES) - # TODO: Consider allowing renaming artist/genre/label here? # Unimplemented: # - readlink # - mknod - # - unlink # - symlink # - link # - opendir @@ -404,9 +483,6 @@ def chmod(self, *_, **__) -> None: # type: ignore def chown(self, *_, **__) -> None: # type: ignore pass - def unlink(self, *_, **__) -> None: # type: ignore - pass - def create(self, *_, **__) -> None: # type: ignore raise fuse.FuseOSError(errno.ENOTSUP) @@ -420,6 +496,7 @@ class ParsedPath: "Genres", "Labels", "Collages", + "Playlists", "New", "Recently Added", ] | None @@ -427,13 +504,17 @@ class ParsedPath: genre: str | None = None label: str | None = None collage: str | None = None + playlist: str | None = None release: str | None = None + release_position: str | None = None file: str | None = None + file_position: str | None = None -# In collages, we print directories with position of the release in the collage. When parsing, -# strip it out. Otherwise we will have to handle this parsing in every method. -POSITION_REGEX = re.compile(r"^\d+\. ") +# In collages, playlists, and releases, we print directories with position of the release/track in +# the collage. When parsing, strip it out. Otherwise we will have to handle this parsing in every +# method. +POSITION_REGEX = re.compile(r"^([^.]+)\. ") # In recently added, we print the date that the release was added to the library. When parsing, # strip it out. ADDED_AT_REGEX = re.compile(r"^\[[\d-]{10}\] ") @@ -451,7 +532,12 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 2: return ParsedPath(view="Releases", release=parts[1]) if len(parts) == 3: - return ParsedPath(view="Releases", release=parts[1], file=parts[2]) + return ParsedPath( + view="Releases", + release=parts[1], + file=POSITION_REGEX.sub("", parts[2]), + file_position=m[1] if (m := POSITION_REGEX.match(parts[2])) else None, + ) raise fuse.FuseOSError(errno.ENOENT) if parts[0] == "2. Releases - New": @@ -460,7 +546,12 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 2 and parts[1].startswith("{NEW} "): return ParsedPath(view="New", release=parts[1]) if len(parts) == 3 and parts[1].startswith("{NEW} "): - return ParsedPath(view="New", release=parts[1], file=parts[2]) + return ParsedPath( + view="New", + release=parts[1], + file=POSITION_REGEX.sub("", parts[2]), + file_position=m[1] if (m := POSITION_REGEX.match(parts[2])) else None, + ) raise fuse.FuseOSError(errno.ENOENT) if parts[0] == "3. Releases - Recently Added": @@ -472,7 +563,8 @@ def parse_virtual_path(path: str) -> ParsedPath: return ParsedPath( view="Recently Added", release=ADDED_AT_REGEX.sub("", parts[1]), - file=parts[2], + file=POSITION_REGEX.sub("", parts[2]), + file_position=m[1] if (m := POSITION_REGEX.match(parts[2])) else None, ) raise fuse.FuseOSError(errno.ENOENT) @@ -484,7 +576,13 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 3: return ParsedPath(view="Artists", artist=parts[1], release=parts[2]) if len(parts) == 4: - return ParsedPath(view="Artists", artist=parts[1], release=parts[2], file=parts[3]) + return ParsedPath( + view="Artists", + artist=parts[1], + release=parts[2], + file=POSITION_REGEX.sub("", parts[3]), + file_position=m[1] if (m := POSITION_REGEX.match(parts[3])) else None, + ) raise fuse.FuseOSError(errno.ENOENT) if parts[0] == "5. Genres": @@ -495,7 +593,13 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 3: return ParsedPath(view="Genres", genre=parts[1], release=parts[2]) if len(parts) == 4: - return ParsedPath(view="Genres", genre=parts[1], release=parts[2], file=parts[3]) + return ParsedPath( + view="Genres", + genre=parts[1], + release=parts[2], + file=POSITION_REGEX.sub("", parts[3]), + file_position=m[1] if (m := POSITION_REGEX.match(parts[3])) else None, + ) raise fuse.FuseOSError(errno.ENOENT) if parts[0] == "6. Labels": @@ -506,7 +610,13 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 3: return ParsedPath(view="Labels", label=parts[1], release=parts[2]) if len(parts) == 4: - return ParsedPath(view="Labels", label=parts[1], release=parts[2], file=parts[3]) + return ParsedPath( + view="Labels", + label=parts[1], + release=parts[2], + file=POSITION_REGEX.sub("", parts[3]), + file_position=m[1] if (m := POSITION_REGEX.match(parts[3])) else None, + ) raise fuse.FuseOSError(errno.ENOENT) if parts[0] == "7. Collages": @@ -516,14 +626,33 @@ def parse_virtual_path(path: str) -> ParsedPath: return ParsedPath(view="Collages", collage=parts[1]) if len(parts) == 3: return ParsedPath( - view="Collages", collage=parts[1], release=POSITION_REGEX.sub("", parts[2]) + view="Collages", + collage=parts[1], + release=POSITION_REGEX.sub("", parts[2]), + release_position=m[1] if (m := POSITION_REGEX.match(parts[2])) else None, ) if len(parts) == 4: return ParsedPath( view="Collages", collage=parts[1], release=POSITION_REGEX.sub("", parts[2]), - file=parts[3], + release_position=m[1] if (m := POSITION_REGEX.match(parts[2])) else None, + file=POSITION_REGEX.sub("", parts[3]), + file_position=m[1] if (m := POSITION_REGEX.match(parts[3])) else None, + ) + raise fuse.FuseOSError(errno.ENOENT) + + if parts[0] == "8. Playlists": + if len(parts) == 1: + return ParsedPath(view="Playlists") + if len(parts) == 2: + return ParsedPath(view="Playlists", playlist=parts[1]) + if len(parts) == 3: + return ParsedPath( + view="Playlists", + playlist=parts[1], + file=POSITION_REGEX.sub("", parts[2]), + file_position=m[1] if (m := POSITION_REGEX.match(parts[2])) else None, ) raise fuse.FuseOSError(errno.ENOENT) diff --git a/rose/virtualfs_test.py b/rose/virtualfs_test.py index 541b6ee..d08d124 100644 --- a/rose/virtualfs_test.py +++ b/rose/virtualfs_test.py @@ -97,6 +97,14 @@ def can_read(p: Path) -> bool: assert not (root / "7. Collages" / "Rose Gold" / "1. r1" / "lalala").exists() assert can_read(root / "7. Collages" / "Rose Gold" / "1. r1" / "01.m4a") + assert (root / "8. Playlists").is_dir() + assert (root / "8. Playlists" / "Lala Lisa").is_dir() + assert (root / "8. Playlists" / "Turtle Rabbit").is_dir() + assert not (root / "8. Playlists" / "lalala").exists() + assert (root / "8. Playlists" / "Lala Lisa" / "1. 01.m4a").is_file() + assert not (root / "8. Playlists" / "Lala Lisa" / "lalala").exists() + assert can_read(root / "8. Playlists" / "Lala Lisa" / "1. 01.m4a") + @pytest.mark.usefixtures("seeded_cache") def test_virtual_filesystem_write_files(config: Config) -> None: @@ -139,6 +147,37 @@ 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: + root = config.fuse_mount_dir + src = config.music_source_dir + + with startfs(config): + # Create playlist. + (root / "8. Playlists" / "New Tee").mkdir(parents=True) + assert (src / "!playlists" / "New Tee.toml").is_file() + # Rename playlist. + (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() + # Delete playlist. + (root / "8. Playlists" / "New Jeans").rmdir() + assert not (src / "!playlists" / "New Jeans.toml").exists() + + def test_virtual_filesystem_toggle_new(config: Config, source_dir: Path) -> None: # noqa: ARG001 dirname = "NewJeans - 1990. I Love NewJeans [K-Pop;R&B] {A Cool Label}" root = config.fuse_mount_dir diff --git a/rose/watcher.py b/rose/watcher.py index 405ecc8..825e1db 100644 --- a/rose/watcher.py +++ b/rose/watcher.py @@ -17,8 +17,10 @@ from rose.cache import ( update_cache_evict_nonexistent_collages, + update_cache_evict_nonexistent_playlists, update_cache_evict_nonexistent_releases, update_cache_for_collages, + update_cache_for_playlists, update_cache_for_releases, ) from rose.config import Config @@ -51,6 +53,7 @@ class WatchdogEvent: type: EventType collage: str | None = None + playlist: str | None = None release: Path | None = None @@ -69,6 +72,7 @@ def on_any_event(self, event: FileSystemEvent) -> None: if etype not in EVENT_TYPES: return + # Collage event. relative_path = path.removeprefix(str(self.config.music_source_dir) + "/") if relative_path.startswith("!collages/"): if not relative_path.endswith(".toml"): @@ -78,6 +82,16 @@ def on_any_event(self, event: FileSystemEvent) -> None: self.queue.put(WatchdogEvent(collage=collage, type=etype)) return + # Playlist event. + if relative_path.startswith("!playlists/"): + if not relative_path.endswith(".toml"): + return + playlist = relative_path.removeprefix("!playlists/").removesuffix(".toml") + logger.debug(f"Queueing {etype} event on playlist {playlist}") + self.queue.put(WatchdogEvent(playlist=playlist, type=etype)) + return + + # Release event. with contextlib.suppress(IndexError): final_path_part = Path(relative_path).parts[0] if final_path_part == "/": @@ -98,17 +112,24 @@ async def handle_event( if e.type == "created" or e.type == "modified": if e.collage: update_cache_for_collages(c, [e.collage]) + elif e.playlist: + update_cache_for_playlists(c, [e.playlist]) elif e.release: update_cache_for_releases(c, [e.release]) elif e.type == "deleted": if e.collage: update_cache_evict_nonexistent_collages(c) + elif e.playlist: + update_cache_evict_nonexistent_playlists(c) elif e.release: update_cache_evict_nonexistent_releases(c) elif e.type == "moved": if e.collage: update_cache_for_collages(c, [e.collage]) update_cache_evict_nonexistent_collages(c) + elif e.playlist: + update_cache_for_playlists(c, [e.playlist]) + update_cache_evict_nonexistent_playlists(c) elif e.release: update_cache_for_releases(c, [e.release]) update_cache_evict_nonexistent_releases(c) @@ -117,7 +138,7 @@ async def handle_event( async def event_processor(c: Config, queue: Queue[WatchdogEvent]) -> None: # pragma: no cover debounce_times: dict[str, float] = {} while True: - await asyncio.sleep(0.01 / WAIT_DIVIDER) + await asyncio.sleep(0.5 / WAIT_DIVIDER) try: event = queue.get(block=False) @@ -131,6 +152,13 @@ async def event_processor(c: Config, queue: Queue[WatchdogEvent]) -> None: # pr await handle_event(c, event) continue + if event.playlist: + logger.info( + f"Updating cache in response to {event.type} event on playlist {event.playlist}" + ) + await handle_event(c, event) + continue + assert event.release is not None # Debounce releases. Reason is documented at top of module. key = event.type + "|" + str(event.release) @@ -144,7 +172,7 @@ async def event_processor(c: Config, queue: Queue[WatchdogEvent]) -> None: # pr logger.info( f"Updating cache in response to {event.type} event on release {event.release.name}" ) - asyncio.create_task(handle_event(c, event, 0.5)) + asyncio.create_task(handle_event(c, event, 2)) def start_watchdog(c: Config) -> None: # pragma: no cover diff --git a/rose/watcher_test.py b/rose/watcher_test.py index 044387c..d0a4873 100644 --- a/rose/watcher_test.py +++ b/rose/watcher_test.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from multiprocessing import Process -from conftest import TEST_COLLAGE_1, TEST_RELEASE_2, TEST_RELEASE_3 +from conftest import TEST_COLLAGE_1, TEST_PLAYLIST_1, TEST_RELEASE_2, TEST_RELEASE_3 from rose.cache import connect from rose.config import Config from rose.watcher import start_watchdog @@ -25,7 +25,7 @@ def retry_for_sec(timeout_sec: float) -> Iterator[None]: start = time.time() while True: yield - time.sleep(0.005) + time.sleep(0.01) if time.time() - start >= timeout_sec: break @@ -67,27 +67,45 @@ def test_watchdog_events(config: Config) -> None: else: raise AssertionError("Failed to find collage in cache.") + # Create playlist. + shutil.copytree(TEST_PLAYLIST_1, src / "!playlists") + for _ in retry_for_sec(2): + with connect(config) as conn: + cursor = conn.execute("SELECT name FROM playlists") + if {r["name"] for r in cursor.fetchall()} != {"Lala Lisa"}: + continue + cursor = conn.execute("SELECT track_id FROM playlists_tracks") + if {r["track_id"] for r in cursor.fetchall()} != {"iloveloona", "ilovetwice"}: + continue + break + else: + raise AssertionError("Failed to find release in cache.") + # Create/rename/delete random files; check that they don't interfere with rest of the test. (src / "hi.nfo").touch() (src / "hi.nfo").rename(src / "!collages" / "bye.haha") - (src / "!collages" / "bye.haha").unlink() + (src / "!collages" / "bye.haha").rename(src / "!playlists" / "bye.haha") + (src / "!playlists" / "bye.haha").unlink() # Delete release. - shutil.rmtree(src / TEST_RELEASE_3.name) + shutil.rmtree(src / TEST_RELEASE_2.name) for _ in retry_for_sec(2): with connect(config) as conn: cursor = conn.execute("SELECT id FROM releases") - if {r["id"] for r in cursor.fetchall()} != {"ilovecarly"}: + if {r["id"] for r in cursor.fetchall()} != {"ilovenewjeans"}: continue cursor = conn.execute("SELECT release_id FROM collages_releases") - if {r["release_id"] for r in cursor.fetchall()} != {"ilovecarly"}: + if {r["release_id"] for r in cursor.fetchall()} != {"ilovenewjeans"}: + continue + cursor = conn.execute("SELECT track_id FROM playlists_tracks") + if len(cursor.fetchall()): continue break else: raise AssertionError("Failed to see release deletion in cache.") # Rename release. - (src / TEST_RELEASE_2.name).rename(src / "lalala") + (src / TEST_RELEASE_3.name).rename(src / "lalala") time.sleep(0.5) for _ in retry_for_sec(2): with connect(config) as conn: @@ -96,7 +114,7 @@ def test_watchdog_events(config: Config) -> None: if len(rows) != 1: continue row = rows[0] - if row["id"] != "ilovecarly": + if row["id"] != "ilovenewjeans": continue if row["source_path"] != str(src / "lalala"): continue @@ -112,7 +130,7 @@ def test_watchdog_events(config: Config) -> None: if {r["name"] for r in cursor.fetchall()} != {"Black Pink"}: continue cursor = conn.execute("SELECT release_id FROM collages_releases") - if {r["release_id"] for r in cursor.fetchall()} != {"ilovecarly"}: + if {r["release_id"] for r in cursor.fetchall()} != {"ilovenewjeans"}: continue break else: @@ -126,4 +144,24 @@ def test_watchdog_events(config: Config) -> None: if cursor.fetchone()[0] == 0: break else: - raise AssertionError("Failed to see collage rename in cache.") + raise AssertionError("Failed to see collage deletion in cache.") + + # Rename playlist. + (src / "!playlists" / "Lala Lisa.toml").rename(src / "!playlists" / "Turtle Rabbit.toml") + for _ in retry_for_sec(2): + with connect(config) as conn: + cursor = conn.execute("SELECT name FROM playlists") + if {r["name"] for r in cursor.fetchall()} == {"Turtle Rabbit"}: + break + else: + raise AssertionError("Failed to see playlist rename in cache.") + + # Delete playlist. + (src / "!playlists" / "Turtle Rabbit.toml").unlink() + for _ in retry_for_sec(2): + with connect(config) as conn: + cursor = conn.execute("SELECT COUNT(*) FROM playlists") + if cursor.fetchone()[0] == 0: + break + else: + raise AssertionError("Failed to see playlist deletion in cache.")