From 81ba357825676f0feaabfb97b2858f654de2f266 Mon Sep 17 00:00:00 2001 From: blissful Date: Wed, 11 Oct 2023 17:00:58 -0400 Subject: [PATCH] pass through cover art --- README.md | 9 ++++-- conftest.py | 24 ++++++++-------- rose/cache/dataclasses.py | 1 + rose/cache/read.py | 57 ++++++++++++++++++++++++++++++-------- rose/cache/read_test.py | 25 ++++++++++++++--- rose/cache/schema.sql | 1 + rose/cache/update.py | 23 +++++++++++++-- rose/virtualfs/__init__.py | 35 +++++++++++++++-------- rose/virtualfs/__test__.py | 5 ++++ 9 files changed, 137 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 1b6b739..f04c58c 100644 --- a/README.md +++ b/README.md @@ -148,10 +148,13 @@ Every directory should follow the format: `$music_source_dir/$album_name/$track. So for example: `$music_source_dir/BLACKPINK - 2016. SQUARE ONE/*.mp3`. -## Supported Filetypes +## Filetypes -Rosé supports MP3, M4A, OGG, OPUS, and FLAC audio files and JPG and PNG image -files. +Rosé supports MP3, M4A, OGG, OPUS, and FLAC audio files. + +Rosé also supports JPEG and PNG cover art. The supported cover art file stems +are `cover`, `folder`, and `art`. The supported cover art file extensions are +`.jpg`, `.jpeg`, and `.png`. ## Tagging diff --git a/conftest.py b/conftest.py index 15ace27..12d0c21 100644 --- a/conftest.py +++ b/conftest.py @@ -53,18 +53,21 @@ def seeded_cache(config: Config) -> Iterator[None]: config.music_source_dir / "r1", config.music_source_dir / "r2", ] - filepaths = [ + musicpaths = [ config.music_source_dir / "r1" / "01.m4a", config.music_source_dir / "r1" / "02.m4a", config.music_source_dir / "r2" / "01.m4a", ] + imagepaths = [ + config.music_source_dir / "r2" / "cover.jpg", + ] with sqlite3.connect(config.cache_database_path) as conn: conn.executescript( f"""\ -INSERT INTO releases (id, source_path, virtual_dirname, title, release_type, release_year, new) -VALUES ('r1', '{dirpaths[0]}', 'r1', 'Release 1', 'album', 2023, true) - , ('r2', '{dirpaths[1]}', 'r2', 'Release 2', 'album', 2021, false); +INSERT INTO releases (id, source_path, cover_image_path, virtual_dirname, title, release_type, release_year, new) +VALUES ('r1', '{dirpaths[0]}', null, 'r1', 'Release 1', 'album', 2023, true) + , ('r2', '{dirpaths[1]}', '{imagepaths[0]}', 'r2', 'Release 2', 'album', 2021, false); INSERT INTO releases_genres (release_id, genre, genre_sanitized) VALUES ('r1', 'Techno', 'Techno') @@ -75,11 +78,10 @@ def seeded_cache(config: Config) -> Iterator[None]: VALUES ('r1', 'Silk Music', 'Silk Music') , ('r2', 'Native State', 'Native State'); -INSERT INTO tracks -(id, source_path, virtual_filename, title, release_id, track_number, disc_number, duration_seconds) -VALUES ('t1', '{filepaths[0]}', '01.m4a', 'Track 1', 'r1', '01', '01', 120) - , ('t2', '{filepaths[1]}', '02.m4a', 'Track 2', 'r1', '02', '01', 240) - , ('t3', '{filepaths[2]}', '01.m4a', 'Track 1', 'r2', '01', '01', 120); +INSERT INTO tracks (id, source_path, virtual_filename, title, release_id, track_number, disc_number, duration_seconds) +VALUES ('t1', '{musicpaths[0]}', '01.m4a', 'Track 1', 'r1', '01', '01', 120) + , ('t2', '{musicpaths[1]}', '02.m4a', 'Track 2', 'r1', '02', '01', 240) + , ('t3', '{musicpaths[2]}', '01.m4a', 'Track 1', 'r2', '01', '01', 120); INSERT INTO releases_artists (release_id, artist, artist_sanitized, role) VALUES ('r1', 'Techno Man', 'Techno Man', 'main') @@ -94,12 +96,12 @@ def seeded_cache(config: Config) -> Iterator[None]: , ('t2', 'Bass Man', 'Bass Man', 'main') , ('t3', 'Violin Woman', 'Violin Woman', 'main') , ('t3', 'Conductor Woman', 'Conductor Woman', 'guest'); - """ + """ # noqa: E501 ) for d in dirpaths: d.mkdir() - for f in filepaths: + for f in musicpaths + imagepaths: f.touch() yield diff --git a/rose/cache/dataclasses.py b/rose/cache/dataclasses.py index e3cdc08..31631b0 100644 --- a/rose/cache/dataclasses.py +++ b/rose/cache/dataclasses.py @@ -12,6 +12,7 @@ class CachedArtist: class CachedRelease: id: str source_path: Path + cover_image_path: Path | None virtual_dirname: str title: str release_type: str diff --git a/rose/cache/read.py b/rose/cache/read.py index 995d71a..f03e6cf 100644 --- a/rose/cache/read.py +++ b/rose/cache/read.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import Iterator @@ -37,6 +38,7 @@ def list_releases( SELECT r.id , r.source_path + , r.cover_image_path , r.virtual_dirname , r.title , r.release_type @@ -86,6 +88,7 @@ def list_releases( yield CachedRelease( id=row["id"], source_path=Path(row["source_path"]), + cover_image_path=Path(row["cover_image_path"]) if row["cover_image_path"] else None, virtual_dirname=row["virtual_dirname"], title=row["title"], release_type=row["release_type"], @@ -97,7 +100,15 @@ def list_releases( ) -def list_tracks(c: Config, release_virtual_dirname: str) -> Iterator[CachedTrack]: +@dataclass +class ReleaseFiles: + tracks: list[CachedTrack] + cover: Path | None + + +def get_release_files(c: Config, release_virtual_dirname: str) -> ReleaseFiles: + rf = ReleaseFiles(tracks=[], cover=None) + with connect(c) as conn: cursor = conn.execute( r""" @@ -131,18 +142,29 @@ def list_tracks(c: Config, release_virtual_dirname: str) -> Iterator[CachedTrack artists: list[CachedArtist] = [] for n, r in zip(row["artist_names"].split(r" \\ "), row["artist_roles"].split(r" \\ ")): artists.append(CachedArtist(name=n, role=r)) - yield CachedTrack( - id=row["id"], - source_path=Path(row["source_path"]), - virtual_filename=row["virtual_filename"], - title=row["title"], - release_id=row["release_id"], - track_number=row["track_number"], - disc_number=row["disc_number"], - duration_seconds=row["duration_seconds"], - artists=artists, + rf.tracks.append( + CachedTrack( + id=row["id"], + source_path=Path(row["source_path"]), + virtual_filename=row["virtual_filename"], + title=row["title"], + release_id=row["release_id"], + track_number=row["track_number"], + disc_number=row["disc_number"], + duration_seconds=row["duration_seconds"], + artists=artists, + ) ) + cursor = conn.execute( + "SELECT cover_image_path FROM releases WHERE virtual_dirname = ?", + (release_virtual_dirname,), + ) + if (row := cursor.fetchone()) and row["cover_image_path"]: + rf.cover = Path(row["cover_image_path"]) + + return rf + def list_artists(c: Config) -> Iterator[str]: with connect(c) as conn: @@ -197,6 +219,19 @@ def track_exists( return None +def cover_exists(c: Config, release_virtual_dirname: str, cover_name: str) -> Path | None: + with connect(c) as conn: + cursor = conn.execute( + "SELECT cover_image_path FROM releases r WHERE r.virtual_dirname = ?", + (release_virtual_dirname,), + ) + if (row := cursor.fetchone()) and row["cover_image_path"]: + p = Path(row["cover_image_path"]) + if p.name == cover_name: + return p + return None + + def artist_exists(c: Config, artist_sanitized: str) -> bool: with connect(c) as conn: cursor = conn.execute( diff --git a/rose/cache/read_test.py b/rose/cache/read_test.py index 46c4bdc..37a7e69 100644 --- a/rose/cache/read_test.py +++ b/rose/cache/read_test.py @@ -5,13 +5,14 @@ from rose.cache.dataclasses import CachedArtist, CachedRelease, CachedTrack from rose.cache.read import ( artist_exists, + cover_exists, genre_exists, + get_release_files, label_exists, list_artists, list_genres, list_labels, list_releases, - list_tracks, release_exists, track_exists, ) @@ -25,6 +26,7 @@ def test_list_releases(config: Config) -> None: CachedRelease( id="r1", source_path=Path(config.music_source_dir / "r1"), + cover_image_path=None, virtual_dirname="r1", title="Release 1", release_type="album", @@ -40,6 +42,7 @@ def test_list_releases(config: Config) -> None: CachedRelease( id="r2", source_path=Path(config.music_source_dir / "r2"), + cover_image_path=Path(config.music_source_dir / "r2" / "cover.jpg"), virtual_dirname="r2", title="Release 2", release_type="album", @@ -58,6 +61,7 @@ def test_list_releases(config: Config) -> None: CachedRelease( id="r1", source_path=Path(config.music_source_dir / "r1"), + cover_image_path=None, virtual_dirname="r1", title="Release 1", release_type="album", @@ -76,6 +80,7 @@ def test_list_releases(config: Config) -> None: CachedRelease( id="r1", source_path=Path(config.music_source_dir / "r1"), + cover_image_path=None, virtual_dirname="r1", title="Release 1", release_type="album", @@ -94,6 +99,7 @@ def test_list_releases(config: Config) -> None: CachedRelease( id="r1", source_path=Path(config.music_source_dir / "r1"), + cover_image_path=None, virtual_dirname="r1", title="Release 1", release_type="album", @@ -110,9 +116,9 @@ def test_list_releases(config: Config) -> None: @pytest.mark.usefixtures("seeded_cache") -def test_list_tracks(config: Config) -> None: - tracks = list(list_tracks(config, "r1")) - assert tracks == [ +def test_get_release_files(config: Config) -> None: + rf = get_release_files(config, "r1") + assert rf.tracks == [ CachedTrack( id="t1", source_path=Path(config.music_source_dir / "r1" / "01.m4a"), @@ -142,6 +148,10 @@ def test_list_tracks(config: Config) -> None: ], ), ] + assert rf.cover is None + + rf = get_release_files(config, "r2") + assert rf.cover == config.music_source_dir / "r2" / "cover.jpg" @pytest.mark.usefixtures("seeded_cache") @@ -175,6 +185,13 @@ def test_track_exists(config: Config) -> None: assert not track_exists(config, "r1", "lalala") +@pytest.mark.usefixtures("seeded_cache") +def test_cover_exists(config: Config) -> None: + assert cover_exists(config, "r2", "cover.jpg") + assert not cover_exists(config, "r2", "cover.png") + assert not cover_exists(config, "r1", "cover.jpg") + + @pytest.mark.usefixtures("seeded_cache") def test_artist_exists(config: Config) -> None: assert artist_exists(config, "Bass Man") diff --git a/rose/cache/schema.sql b/rose/cache/schema.sql index 6555b27..950f8fd 100644 --- a/rose/cache/schema.sql +++ b/rose/cache/schema.sql @@ -15,6 +15,7 @@ INSERT INTO release_type_enum (value) VALUES CREATE TABLE releases ( id TEXT PRIMARY KEY, source_path TEXT NOT NULL UNIQUE, + cover_image_path TEXT, virtual_dirname TEXT NOT NULL UNIQUE, title TEXT NOT NULL, release_type TEXT NOT NULL REFERENCES release_type_enum(value), diff --git a/rose/cache/update.py b/rose/cache/update.py index 7f783a4..921943e 100644 --- a/rose/cache/update.py +++ b/rose/cache/update.py @@ -15,6 +15,10 @@ logger = logging.getLogger(__name__) +VALID_COVER_FILENAMES = [ + stem + ext for stem in ["cover", "folder", "art"] for ext in [".jpg", ".jpeg", ".png"] +] + SUPPORTED_EXTENSIONS = [ ".mp3", ".m4a", @@ -128,9 +132,19 @@ def update_cache_for_release(c: Config, release_dir: Path) -> Path: break virtual_dirname = f"{original_virtual_dirname} [{collision_no}]" + # Search for cover art. + cover_image_path = None + for cn in VALID_COVER_FILENAMES: + p = release_dir / cn + if p.is_file(): + cover_image_path = p.resolve() + break + + # Construct the cached release. release = CachedRelease( id=release_id, source_path=release_dir.resolve(), + cover_image_path=cover_image_path, virtual_dirname=virtual_dirname, title=tags.album or "Unknown Release", release_type=( @@ -149,13 +163,16 @@ def update_cache_for_release(c: Config, release_dir: Path) -> Path: for name in names: release.artists.append(CachedArtist(name=name, role=role)) + # Upsert the release. conn.execute( """ INSERT INTO releases - (id, source_path, virtual_dirname, title, release_type, release_year, new) - VALUES (?, ?, ?, ?, ?, ?, ?) + (id, source_path, cover_image_path, virtual_dirname, title, release_type, + release_year, new) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET source_path = ?, + cover_image_path = ?, virtual_dirname = ?, title = ?, release_type = ?, @@ -165,12 +182,14 @@ def update_cache_for_release(c: Config, release_dir: Path) -> Path: ( release.id, str(release.source_path), + str(release.cover_image_path), release.virtual_dirname, release.title, release.release_type, release.release_year, release.new, str(release.source_path), + str(release.cover_image_path), release.virtual_dirname, release.title, release.release_type, diff --git a/rose/virtualfs/__init__.py b/rose/virtualfs/__init__.py index fb82f03..e2d17af 100644 --- a/rose/virtualfs/__init__.py +++ b/rose/virtualfs/__init__.py @@ -12,13 +12,14 @@ from rose.cache.read import ( artist_exists, + cover_exists, genre_exists, + get_release_files, label_exists, list_artists, list_genres, list_labels, list_releases, - list_tracks, release_exists, track_exists, ) @@ -42,9 +43,11 @@ def getattr(self, path: str) -> fuse.Stat: if p.view == "root": return mkstat("dir") - elif p.album and p.track: - if tp := track_exists(self.config, p.album, p.track): + elif p.album and p.file: + if tp := track_exists(self.config, p.album, p.file): return mkstat("file", tp) + if cp := cover_exists(self.config, p.album, p.file): + return mkstat("file", cp) elif p.album: if rp := release_exists(self.config, p.album): return mkstat("dir", rp) @@ -77,8 +80,11 @@ def readdir(self, path: str, _: Any) -> Iterator[fuse.Direntry]: fuse.Direntry("labels"), ] elif p.album: - for track in list_tracks(self.config, p.album): + rf = get_release_files(self.config, p.album) + for track in rf.tracks: yield fuse.Direntry(track.virtual_filename) + if rf.cover: + yield fuse.Direntry(rf.cover.name) elif p.artist or p.genre or p.label or p.view == "albums": for album in list_releases( self.config, @@ -104,9 +110,14 @@ def read(self, path: str, size: int, offset: int) -> bytes: p = parse_virtual_path(path) logger.debug(f"Parsed read path as {p}") - if p.album and p.track: - for track in list_tracks(self.config, p.album): - if track.virtual_filename == p.track: + if p.album and p.file: + rf = get_release_files(self.config, p.album) + if rf.cover and p.file == rf.cover.name: + with rf.cover.open("rb") as fp: + fp.seek(offset) + return fp.read(size) + for track in rf.tracks: + if track.virtual_filename == p.file: with track.source_path.open("rb") as fp: fp.seek(offset) return fp.read(size) @@ -134,7 +145,7 @@ class ParsedPath: genre: str | None = None label: str | None = None album: str | None = None - track: str | None = None + file: str | None = None def parse_virtual_path(path: str) -> ParsedPath: @@ -149,7 +160,7 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 2: return ParsedPath(view="albums", album=parts[1]) if len(parts) == 3: - return ParsedPath(view="albums", album=parts[1], track=parts[2]) + return ParsedPath(view="albums", album=parts[1], file=parts[2]) raise OSError(errno.ENOENT, "No such file or directory") if parts[0] == "artists": @@ -160,7 +171,7 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 3: return ParsedPath(view="artists", artist=parts[1], album=parts[2]) if len(parts) == 4: - return ParsedPath(view="artists", artist=parts[1], album=parts[2], track=parts[3]) + return ParsedPath(view="artists", artist=parts[1], album=parts[2], file=parts[3]) raise OSError(errno.ENOENT, "No such file or directory") if parts[0] == "genres": @@ -171,7 +182,7 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 3: return ParsedPath(view="genres", genre=parts[1], album=parts[2]) if len(parts) == 4: - return ParsedPath(view="genres", genre=parts[1], album=parts[2], track=parts[3]) + return ParsedPath(view="genres", genre=parts[1], album=parts[2], file=parts[3]) raise OSError(errno.ENOENT, "No such file or directory") if parts[0] == "labels": @@ -182,7 +193,7 @@ def parse_virtual_path(path: str) -> ParsedPath: if len(parts) == 3: return ParsedPath(view="labels", label=parts[1], album=parts[2]) if len(parts) == 4: - return ParsedPath(view="labels", label=parts[1], album=parts[2], track=parts[3]) + return ParsedPath(view="labels", label=parts[1], album=parts[2], file=parts[3]) raise OSError(errno.ENOENT, "No such file or directory") raise OSError(errno.ENOENT, "No such file or directory") diff --git a/rose/virtualfs/__test__.py b/rose/virtualfs/__test__.py index f7081b2..f18a09d 100644 --- a/rose/virtualfs/__test__.py +++ b/rose/virtualfs/__test__.py @@ -39,6 +39,11 @@ def can_read(p: Path) -> bool: assert not (root / "albums" / "r1" / "lala.m4a").exists() assert can_read(root / "albums" / "r1" / "01.m4a") + assert (root / "albums" / "r2" / "cover.jpg").is_file() + assert can_read(root / "albums" / "r2" / "cover.jpg") + assert not (root / "albums" / "r1" / "cover.jpg").exists() + assert not (root / "albums" / "r2" / "cover.png").exists() + assert (root / "artists").is_dir() assert (root / "artists" / "Bass Man").is_dir() assert not (root / "artists" / "lalala").exists()