diff --git a/CLAUDE.md b/CLAUDE.md index 4c90446..cc41ba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ A-B repeat · crossfade (static / smart-album-aware / dynamic-tempo-aware) · ga ### Library ([`docs/features/library.md`](docs/features/library.md)) -Scanner with parallel BLAKE3 extraction + transactional commit + fs-watcher silent rescan + `scan:progress` toast · folder-cover fallback (cover/folder/front/albumart…) · advanced search (FTS5 + structured filters: genre, year, BPM, duration, format, Hi-Res, liked) · tag editor round-trips through lofty + DB · track ratings (POPM round-trip, half-step UI) · duplicate detection (BLAKE3 grouping) · folder removal + drag-and-drop import · listening history (`HistoryView` with month scrubber) · album grouping with sticky compilation flag (see cross-cutting rules above). +Scanner with parallel BLAKE3 extraction + transactional commit + fs-watcher silent rescan + `scan:progress` toast · folder-cover fallback (cover/folder/front/albumart…) · local artist images (`artist.jpg` or `.jpg` resolved up to 3 parent dirs, prioritised over Deezer; `rescan_local_artist_images` backfills existing libraries) · advanced search (FTS5 + structured filters: genre, year, BPM, duration, format, Hi-Res, liked) · tag editor round-trips through lofty + DB · track ratings (POPM round-trip, half-step UI) · duplicate detection (BLAKE3 grouping) · folder removal + drag-and-drop import · listening history (`HistoryView` with month scrubber) · album grouping with sticky compilation flag (see cross-cutting rules above). ### UI ([`docs/features/ui.md`](docs/features/ui.md)) diff --git a/bun.lock b/bun.lock index f83de4d..db2c176 100644 --- a/bun.lock +++ b/bun.lock @@ -15,12 +15,12 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-updater": "^2.10.1", - "i18next": "^26.1.0", + "i18next": "^26.2.0", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-i18next": "^17.0.7", + "react-i18next": "^17.0.8", "tailwindcss": "^4.3.0", }, "devDependencies": { @@ -30,8 +30,8 @@ "@tauri-apps/cli": "^2.11.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "eslint": "^10.3.0", + "@vitejs/plugin-react": "^6.0.2", + "eslint": "^10.4.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", @@ -291,29 +291,29 @@ "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], - "@tauri-apps/cli": ["@tauri-apps/cli@2.11.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.1", "@tauri-apps/cli-darwin-x64": "2.11.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1", "@tauri-apps/cli-linux-arm64-gnu": "2.11.1", "@tauri-apps/cli-linux-arm64-musl": "2.11.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1", "@tauri-apps/cli-linux-x64-gnu": "2.11.1", "@tauri-apps/cli-linux-x64-musl": "2.11.1", "@tauri-apps/cli-win32-arm64-msvc": "2.11.1", "@tauri-apps/cli-win32-ia32-msvc": "2.11.1", "@tauri-apps/cli-win32-x64-msvc": "2.11.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ=="], + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="], - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg=="], + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="], - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig=="], + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg=="], - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q=="], + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.2", "", { "os": "linux", "cpu": "arm" }, "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA=="], - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA=="], + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw=="], - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg=="], + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw=="], - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw=="], + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.2", "", { "os": "linux", "cpu": "none" }, "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ=="], - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw=="], + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw=="], - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA=="], + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw=="], - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ=="], + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA=="], - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA=="], + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA=="], - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A=="], + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="], diff --git a/docs/features/library.md b/docs/features/library.md index 0c3b5ba..e9498a1 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -59,3 +59,35 @@ UI: [`DuplicatesModal`](../../src/components/common/DuplicatesModal.tsx) launche ## Cover picker [`commands/deezer.rs::set_album_artwork_from_deezer`](../../src-tauri/src/commands/deezer.rs) and `set_album_artwork_from_file`. The file picker validates magic bytes (JPEG / PNG / WebP) before accepting upload, and `batch_fetch_missing_album_covers` walks all albums without an `artwork_id`, querying Deezer in parallel with a small concurrency cap. + +## Local artist images + +Scanner sidecar lookup, mirror of the folder-cover fallback but resolved against the track's ancestors instead of the immediate parent. + +[`commands/scan.rs::extract_artist_image`](../../src-tauri/src/commands/scan.rs) walks up to **3 parent directories** from each track and accepts the first match where either: + +- the filename stem is one of `ARTIST_IMAGE_STEMS = ["artist", "performer", "band"]`, **or** +- the stem's `canonical_name(...)` equals the artist's canonical name (covers `Daft Punk.jpg` at the root of a `Daft Punk/` folder). + +Both common layouts from issue #31 work out of the box: + +- `Music///track.flac` → matches `artist.jpg` two levels up. +- `Music//track.flac` → matches `.jpg` sitting beside the album folder (strict name-match so an unrelated `cover.jpg` is never mistaken for an artist photo). + +Hash-addressed via BLAKE3 into the shared `artwork/.{jpg,png,webp,…}` cache and linked through the existing `artist.artwork_id → artwork` foreign key (no schema change). The `UPDATE … WHERE artwork_id IS NULL` guard means scanner runs never overwrite a manually uploaded image or a previously cached Deezer picture. + +Resolution priority in [`commands/browse.rs::get_artist_detail`](../../src-tauri/src/commands/browse.rs) is now: **local sidecar → Deezer cache → live Deezer fetch** (last skipped when offline). [`ArtistDetailView`](../../src/components/views/ArtistDetailView.tsx) prefers `artwork_path` over `picture_path` and refuses to clobber a local image with a late-arriving Deezer response. + +The `"Various Artists"` sentinel is explicitly excluded so a compilation folder never inherits a stray album cover as an artist photo. + +For libraries scanned before the feature shipped, [`commands/scan.rs::rescan_local_artist_images`](../../src-tauri/src/commands/scan.rs) (exposed as **Settings → Library → Local artist images**) walks every `artist WHERE artwork_id IS NULL` and probes up to 16 tracks per artist with `extract_artist_image`, stopping at the first hit. Already-linked rows are filtered out at the SQL level, so the rescan is cheap to re-run. + +### Manual override + +The pencil overlay on the artist photo in [`ArtistDetailView`](../../src/components/views/ArtistDetailView.tsx) opens [`ArtistImagePickerModal`](../../src/components/common/ArtistImagePickerModal.tsx), which exposes three actions backed by [`commands/deezer.rs`](../../src-tauri/src/commands/deezer.rs): + +- **Search Deezer** → `search_artists_deezer` + `set_artist_artwork_from_deezer` (downloads the chosen picture into the profile artwork cache, marks source `"deezer"`). +- **Pick a local file** → `set_artist_artwork_from_file` (same magic-byte validation as the album cover picker: jpg / png / webp). +- **Remove image** → `clear_artist_artwork` sets `artist.artwork_id = NULL` so the next render falls back through the resolution chain (Deezer cache → live fetch). + +Both `set_artist_artwork_from_*` overwrite `artwork_id` unconditionally — an explicit user pick beats any automatic resolution. diff --git a/docs/features/ui.md b/docs/features/ui.md index 04e5cff..d61ccc6 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -110,11 +110,11 @@ Track tables themselves are **borderless** — no `rounded-2xl border bg-white` Right side of [`PlayerBar`](../../src/components/player/PlayerBar.tsx) is the highest-pressure real estate in the UI — every new feature wants an icon there. To keep the bar from running out of width on narrow windows, controls cluster by frequency: -| Tier | Controls | Where | -| ------------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| **Primary** | Lyrics, Queue, Device picker, "⋯", Volume, **Mini-player**, **Fullscreen** | Always visible. Spotify-style right cluster (mini-player + fullscreen) sits after volume | -| **Overflow** | Playback speed (slider + presets), A-B loop, Sleep timer (panel) | [`MoreActionsMenu`](../../src/components/player/MoreActionsMenu.tsx) — "⋯" popover; the trigger itself is hidden when nothing inside is left | -| **Pinnable** | A-B loop, Sleep timer (promote to primary) | Toggle in Settings → Lecture (see below) | +| Tier | Controls | Where | +| ------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| **Primary** | Lyrics, Queue, Device picker, "⋯", Volume, **Mini-player**, **Fullscreen** | Always visible. Spotify-style right cluster (mini-player + fullscreen) sits after volume | +| **Overflow** | Playback speed (slider + presets), A-B loop, Sleep timer (panel) | [`MoreActionsMenu`](../../src/components/player/MoreActionsMenu.tsx) — "⋯" popover; the trigger itself is hidden when nothing inside is left | +| **Pinnable** | A-B loop, Sleep timer (promote to primary) | Toggle in Settings → Lecture (see below) | When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage data or user feedback warrants it. If both placements make sense, expose a pin toggle. The "⋯" trigger auto-hides when its menu would be empty. diff --git a/package.json b/package.json index 8c6e04e..fd7061c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", "@eslint/js": "^10.0.1", - "@tauri-apps/cli": "^2.11.1", + "@tauri-apps/cli": "^2.11.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", diff --git a/public/splash.html b/public/splash.html index 81df78e..bbf8ef6 100644 --- a/public/splash.html +++ b/public/splash.html @@ -30,7 +30,8 @@ align-items: center; justify-content: center; gap: 22px; - background: radial-gradient( + background: + radial-gradient( 120% 80% at 50% 0%, rgba(16, 185, 129, 0.18) 0%, rgba(16, 185, 129, 0) 60% diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index efc7a3b..e84b539 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5516,9 +5516,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" dependencies = [ "anyhow", "bytes", @@ -5569,9 +5569,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" dependencies = [ "anyhow", "cargo_toml", @@ -5590,9 +5590,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" dependencies = [ "base64 0.22.1", "brotli", @@ -5617,9 +5617,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -5631,9 +5631,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" dependencies = [ "anyhow", "glob", @@ -5773,9 +5773,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" dependencies = [ "cookie", "dpi", @@ -5798,9 +5798,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", @@ -5824,9 +5824,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" dependencies = [ "anyhow", "brotli", diff --git a/src-tauri/src/commands/browse.rs b/src-tauri/src/commands/browse.rs index 05defb0..e0b8aff 100644 --- a/src-tauri/src/commands/browse.rs +++ b/src-tauri/src/commands/browse.rs @@ -1013,13 +1013,11 @@ pub async fn get_genre_detail( let profile_id = state.require_profile_id().await?; let artwork_dir = state.paths.profile_artwork_dir(profile_id); - let header = sqlx::query_as::<_, GenreHeaderRaw>( - r#"SELECT id, name FROM genre WHERE id = ?"#, - ) - .bind(genre_id) - .fetch_optional(&pool) - .await? - .ok_or_else(|| crate::error::AppError::Other("genre not found".into()))?; + let header = sqlx::query_as::<_, GenreHeaderRaw>(r#"SELECT id, name FROM genre WHERE id = ?"#) + .bind(genre_id) + .fetch_optional(&pool) + .await? + .ok_or_else(|| crate::error::AppError::Other("genre not found".into()))?; let rows = sqlx::query_as::<_, GenreTrackRaw>( r#" diff --git a/src-tauri/src/commands/deezer.rs b/src-tauri/src/commands/deezer.rs index 9230c80..648c52f 100644 --- a/src-tauri/src/commands/deezer.rs +++ b/src-tauri/src/commands/deezer.rs @@ -571,6 +571,151 @@ pub async fn set_album_artwork_from_file( Ok(()) } +// ── Artist image management ───────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct DeezerArtistLite { + pub deezer_id: i64, + pub name: String, + pub picture_url: Option, + pub nb_fan: Option, +} + +/// Search Deezer artists for the artist-image picker. Capped to 20 hits +/// to keep the UI grid readable. +#[tauri::command] +pub async fn search_artists_deezer(query: String) -> AppResult> { + if crate::offline::is_offline() { + return Ok(Vec::new()); + } + let client = DeezerClient::new(); + let hits = client + .search_artist(&query) + .await + .map_err(|err| AppError::Other(format!("deezer artist search failed: {err}")))?; + + Ok(hits + .into_iter() + .take(20) + .map(|h| DeezerArtistLite { + deezer_id: h.id, + name: h.name, + picture_url: h.picture_xl.or(h.picture_big).or(h.picture_medium), + nb_fan: h.nb_fan, + }) + .collect()) +} + +/// Link a specific Deezer artist photo (by Deezer ID) to a local +/// `artist` row. Downloads the picture into the profile artwork cache +/// and overwrites `artist.artwork_id` unconditionally — explicit user +/// pick, so we override any existing image (local sidecar, prior fetch). +#[tauri::command] +pub async fn set_artist_artwork_from_deezer( + state: tauri::State<'_, AppState>, + artist_id: i64, + deezer_artist_id: i64, +) -> AppResult<()> { + if crate::offline::is_offline() { + return Err(AppError::Other("offline mode is enabled".into())); + } + let pool = state.require_profile_pool().await?; + let profile_id = state.require_profile_id().await?; + let profile_artwork_dir = state.paths.profile_artwork_dir(profile_id); + std::fs::create_dir_all(&profile_artwork_dir)?; + + let client = DeezerClient::new(); + let hit = client + .get_artist(deezer_artist_id) + .await + .map_err(|err| AppError::Other(format!("deezer get_artist failed: {err}")))?; + + let picture_url = hit + .picture_xl + .clone() + .or_else(|| hit.picture_big.clone()) + .or_else(|| hit.picture_medium.clone()) + .ok_or_else(|| AppError::Other("deezer artist has no picture".into()))?; + + let bytes = download_image_bytes(&picture_url).await?; + let hash = blake3::hash(&bytes).to_hex().to_string(); + let format = "jpg"; + let target = profile_artwork_dir.join(format!("{hash}.{format}")); + if !target.exists() { + std::fs::write(&target, &bytes)?; + } + crate::thumbnails::spawn_thumbnail_job(target, profile_artwork_dir.clone(), hash.clone()); + + let artwork_id = upsert_artwork_row(&pool, &hash, format, "deezer").await?; + let res = sqlx::query("UPDATE artist SET artwork_id = ? WHERE id = ?") + .bind(artwork_id) + .bind(artist_id) + .execute(&pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::Other(format!("artist {artist_id} not found"))); + } + + Ok(()) +} + +/// Manually upload an image file as the artist photo. Same magic-byte +/// validation as `set_album_artwork_from_file` (jpg / png / webp). +#[tauri::command] +pub async fn set_artist_artwork_from_file( + state: tauri::State<'_, AppState>, + artist_id: i64, + file_path: String, +) -> AppResult<()> { + let pool = state.require_profile_pool().await?; + let profile_id = state.require_profile_id().await?; + let profile_artwork_dir = state.paths.profile_artwork_dir(profile_id); + std::fs::create_dir_all(&profile_artwork_dir)?; + + let bytes = std::fs::read(&file_path)?; + let format = detect_image_format(&bytes).ok_or_else(|| { + AppError::Other("unsupported image format (expected jpg/png/webp)".into()) + })?; + + let hash = blake3::hash(&bytes).to_hex().to_string(); + let target = profile_artwork_dir.join(format!("{hash}.{format}")); + if !target.exists() { + std::fs::write(&target, &bytes)?; + } + crate::thumbnails::spawn_thumbnail_job(target, profile_artwork_dir.clone(), hash.clone()); + + let artwork_id = upsert_artwork_row(&pool, &hash, format, "manual").await?; + let res = sqlx::query("UPDATE artist SET artwork_id = ? WHERE id = ?") + .bind(artwork_id) + .bind(artist_id) + .execute(&pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::Other(format!("artist {artist_id} not found"))); + } + + Ok(()) +} + +/// Detach the current artist image so the resolution chain falls back +/// to the Deezer cache / live fetch. The orphaned `artwork` row (if no +/// longer referenced) is left in place — a future GC pass can sweep it. +#[tauri::command] +pub async fn clear_artist_artwork( + state: tauri::State<'_, AppState>, + artist_id: i64, +) -> AppResult<()> { + let pool = state.require_profile_pool().await?; + let res = sqlx::query("UPDATE artist SET artwork_id = NULL WHERE id = ?") + .bind(artist_id) + .execute(&pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::Other(format!("artist {artist_id} not found"))); + } + Ok(()) +} + /// Walk every artist that doesn't have a fresh `metadata_artist` /// cache row and run the standard Deezer + Last.fm enrichment on each. /// Throttles ~5 req/s so the public Deezer API stays happy. Emits @@ -722,6 +867,12 @@ fn detect_image_format(bytes: &[u8]) -> Option<&'static str> { None } +/// Hard cap on a single image download. Deezer's `picture_xl` / +/// `cover_xl` payloads top out around 200 KB; 10 MiB is generous +/// headroom while still guarding against a hostile (or compromised) +/// remote that streams unbounded data into our process memory. +const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; + async fn download_image_bytes(url: &str) -> AppResult> { let client = reqwest::Client::builder() .user_agent("WaveFlow/0.1") @@ -729,7 +880,7 @@ async fn download_image_bytes(url: &str) -> AppResult> { .build() .map_err(|err| AppError::Other(format!("http client build failed: {err}")))?; - let resp = client + let mut resp = client .get(url) .send() .await @@ -740,9 +891,35 @@ async fn download_image_bytes(url: &str) -> AppResult> { resp.status() ))); } - let bytes = resp - .bytes() + + // Early-reject when the server is honest about its size — saves us + // pulling a single chunk we'd throw away anyway. + if let Some(len) = resp.content_length() { + if len as usize > MAX_IMAGE_BYTES { + return Err(AppError::Other(format!( + "image too large ({} bytes, max {})", + len, MAX_IMAGE_BYTES, + ))); + } + } + + // Pull chunk-by-chunk via `Response::chunk()` (built-in, no need + // for the `stream` feature on reqwest) so a server lying about + // Content-Length — or chunked transfer with no length at all — + // still can't OOM us. + let mut bytes = Vec::with_capacity(64 * 1024); + while let Some(chunk) = resp + .chunk() .await - .map_err(|err| AppError::Other(format!("read failed: {err}")))?; - Ok(bytes.to_vec()) + .map_err(|err| AppError::Other(format!("read failed: {err}")))? + { + if bytes.len() + chunk.len() > MAX_IMAGE_BYTES { + return Err(AppError::Other(format!( + "image exceeds max size ({} bytes)", + MAX_IMAGE_BYTES, + ))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) } diff --git a/src-tauri/src/commands/lyrics.rs b/src-tauri/src/commands/lyrics.rs index c651e4f..6746273 100644 --- a/src-tauri/src/commands/lyrics.rs +++ b/src-tauri/src/commands/lyrics.rs @@ -1088,7 +1088,8 @@ mod tests { #[test] fn detect_format_lrc() { - let sample = "[ar:Some Artist]\n[ti:Some Title]\n[00:01.00]First line\n[00:05.50]Second line"; + let sample = + "[ar:Some Artist]\n[ti:Some Title]\n[00:01.00]First line\n[00:05.50]Second line"; assert_eq!(detect_format(sample), LyricsFormat::Lrc); } diff --git a/src-tauri/src/commands/profile_io.rs b/src-tauri/src/commands/profile_io.rs index ded8a59..a104dcd 100644 --- a/src-tauri/src/commands/profile_io.rs +++ b/src-tauri/src/commands/profile_io.rs @@ -216,9 +216,7 @@ pub async fn import_profile( // this step sqlx refuses the import with // "migration X was previously applied but has been modified". // See `.gitattributes` for the forward fix. - if let Err(err) = - normalise_migration_checksums(&state.paths.profile_db(new_profile_id)).await - { + if let Err(err) = normalise_migration_checksums(&state.paths.profile_db(new_profile_id)).await { cleanup_partial_profile(&state, new_profile_id).await; return Err(err); } @@ -227,18 +225,16 @@ pub async fn import_profile( // (the source might be older than the local schema) replay // immediately. This matches the create_profile flow and gives // the user a usable profile by the time the call returns. - let pool = match db::profile_db::open( - &state.paths.profile_db(new_profile_id), - &state.paths.app_db, - ) - .await - { - Ok(pool) => pool, - Err(err) => { - cleanup_partial_profile(&state, new_profile_id).await; - return Err(err); - } - }; + let pool = + match db::profile_db::open(&state.paths.profile_db(new_profile_id), &state.paths.app_db) + .await + { + Ok(pool) => pool, + Err(err) => { + cleanup_partial_profile(&state, new_profile_id).await; + return Err(err); + } + }; pool.close().await; Ok(new_profile_id) diff --git a/src-tauri/src/commands/scan.rs b/src-tauri/src/commands/scan.rs index 4e780eb..d9a72fa 100644 --- a/src-tauri/src/commands/scan.rs +++ b/src-tauri/src/commands/scan.rs @@ -353,6 +353,144 @@ fn extract_folder_cover(track_path: &Path, artwork_dir: &Path) -> Option.jpg` convention. +const ARTIST_IMAGE_STEMS: &[&str] = &["artist", "performer", "band"]; + +/// Maximum number of parent directories walked upward from the track to +/// find an artist photo. Covers the two common layouts called out in +/// issue #31: +/// 1. `///track.flac` → 2 levels up (`/`). +/// 2. `//track.flac` → 1 level up (`/`), +/// and even the album folder itself can hold an `.jpg`. +/// +/// 3 covers the occasional `///CD1/track.flac` rip. +const ARTIST_IMAGE_MAX_DEPTH: usize = 3; + +/// Look for a sidecar artist image next to the track. Walks up to +/// `ARTIST_IMAGE_MAX_DEPTH` parent directories from `track_path` and +/// accepts the first match where either: +/// - the file stem is in [`ARTIST_IMAGE_STEMS`] (`artist.jpg`, +/// `performer.png`, …), or +/// - the file stem's canonical form equals `artist_canonical` (covers +/// `Daft Punk.jpg` sitting at the root of a `Daft Punk/` folder). +/// +/// Hash-addressed write into `artwork_dir` like every other cover so a +/// later GC can dedup across artists and albums. +fn extract_artist_image( + track_path: &Path, + artist_canonical: &str, + artwork_dir: &Path, +) -> Option { + if artist_canonical.is_empty() { + return None; + } + + let mut current = track_path.parent(); + for _ in 0..ARTIST_IMAGE_MAX_DEPTH { + let Some(dir) = current else { break }; + if let Some(found) = find_artist_image_in_dir(dir, artist_canonical) { + return write_artist_image(&found, artwork_dir); + } + current = dir.parent(); + } + None +} + +fn find_artist_image_in_dir(dir: &Path, artist_canonical: &str) -> Option { + let entries = fs::read_dir(dir).ok()?; + let mut named_match: Option = None; + let mut stem_match: Option<(usize, PathBuf)> = None; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()); + let ext = path + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()); + let (Some(stem), Some(ext)) = (stem, ext) else { + continue; + }; + if !FOLDER_COVER_EXTENSIONS.contains(&ext.as_str()) { + continue; + } + if canonical_name(&stem) == artist_canonical { + named_match.get_or_insert(path); + continue; + } + if let Some(rank) = ARTIST_IMAGE_STEMS.iter().position(|s| *s == stem) { + match &stem_match { + Some((current_rank, _)) if *current_rank <= rank => {} + _ => stem_match = Some((rank, path)), + } + } + } + + named_match.or(stem_match.map(|(_, p)| p)) +} + +fn write_artist_image(picked: &Path, artwork_dir: &Path) -> Option { + let bytes = fs::read(picked).ok()?; + if bytes.is_empty() { + return None; + } + let hash = blake3::hash(&bytes).to_hex().to_string(); + let format = picked + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "jpg".to_string()); + let format = if format == "jpeg" { + "jpg".to_string() + } else { + format + }; + + let out_path = artwork_dir.join(format!("{}.{}", &hash, &format)); + if !out_path.exists() { + if let Err(err) = fs::write(&out_path, &bytes) { + tracing::warn!( + path = %out_path.display(), + error = %err, + "failed to write artist image", + ); + return None; + } + } + crate::thumbnails::spawn_thumbnail_job(out_path, artwork_dir.to_path_buf(), hash.clone()); + Some(ExtractedCover { + hash, + format, + source: "folder", + }) +} + +/// Best-effort: link a freshly resolved local artist image to its `artist` +/// row when the row has no artwork yet. Idempotent — re-running with a +/// already-linked artist is a no-op (the `IS NULL` guard prevents +/// overwriting a manually uploaded picture). +async fn link_local_artist_image( + conn: &mut sqlx::SqliteConnection, + artist_id: i64, + cover: &ExtractedCover, +) -> AppResult<()> { + let artwork_id = upsert_artwork(conn, &cover.hash, &cover.format, cover.source).await?; + sqlx::query("UPDATE artist SET artwork_id = ? WHERE id = ? AND artwork_id IS NULL") + .bind(artwork_id) + .bind(artist_id) + .execute(&mut *conn) + .await?; + Ok(()) +} + /// Extract a 0-255 rating from a tag. POPM frames (ID3v2) are stored by /// lofty as raw `ItemValue::Binary` under `ItemKey::Popularimeter`: the /// frame body is `\0`, so the rating is @@ -929,6 +1067,47 @@ pub(crate) async fn upsert_album( Ok(Some(result.last_insert_rowid())) } +/// Walk every artist name parsed from `raw`, pair it with its `artist.id` +/// from `artist_ids` (positionally aligned), and try to resolve a sidecar +/// artist image from `track_path`. Idempotent — artists that already have +/// an `artwork_id` are skipped by [`link_local_artist_image`]. +/// +/// Skips the "Various Artists" sentinel: a compilation folder never holds +/// a meaningful artist photo and we'd just pin a random album cover to it. +pub(crate) async fn maybe_link_artist_images( + conn: &mut sqlx::SqliteConnection, + artist_raw: Option<&str>, + artist_ids: &[i64], + track_path: &Path, + artwork_dir: &Path, +) -> AppResult<()> { + let Some(raw) = artist_raw else { + return Ok(()); + }; + let names = split_artist_name(raw); + let va_canon = canonical_name(VARIOUS_ARTISTS_LABEL); + for (name, id) in names.iter().zip(artist_ids.iter()) { + let canon = canonical_name(name); + if canon.is_empty() || canon == va_canon { + continue; + } + // Cheap pre-check so we don't walk the FS when the artist already + // has artwork (Deezer fetch, manual upload, or earlier scan). + let has_artwork: Option = + sqlx::query_scalar("SELECT 1 FROM artist WHERE id = ? AND artwork_id IS NOT NULL") + .bind(id) + .fetch_optional(&mut *conn) + .await?; + if has_artwork.is_some() { + continue; + } + if let Some(cover) = extract_artist_image(track_path, &canon, artwork_dir) { + link_local_artist_image(&mut *conn, *id, &cover).await?; + } + } + Ok(()) +} + /// Resolve a raw multi-artist string (e.g. `"A, B; C"`) to a vector of /// artist row IDs. The first entry becomes the track's primary artist. /// Empty / whitespace-only inputs yield an empty vector. @@ -1289,6 +1468,28 @@ pub(crate) async fn scan_folder_inner( .await?; } } + // Backfill local artist images AFTER the optional + // track_artist rebuild — otherwise newly created + // artist IDs (when current_count != splits.len()) + // would be skipped on first encounter. Cheap because + // already-linked artists are filtered by the + // `IS NOT NULL` pre-check inside the helper. + let track_path = Path::new(&extracted.abs_path); + let current_ids: Vec = sqlx::query_scalar( + "SELECT artist_id FROM track_artist + WHERE track_id = ? ORDER BY position", + ) + .bind(existing_track_id) + .fetch_all(&mut *tx) + .await?; + maybe_link_artist_images( + &mut tx, + Some(raw), + ¤t_ids, + track_path, + artwork_dir, + ) + .await?; } summary.skipped += 1; @@ -1329,6 +1530,15 @@ pub(crate) async fn scan_folder_inner( .await?; } + maybe_link_artist_images( + &mut tx, + extracted.artist.as_deref(), + &artist_ids, + Path::new(&extracted.abs_path), + artwork_dir, + ) + .await?; + sqlx::query( "UPDATE track SET folder_id = ?, @@ -1427,6 +1637,15 @@ pub(crate) async fn scan_folder_inner( .await?; } + maybe_link_artist_images( + &mut tx, + extracted.artist.as_deref(), + &artist_ids, + Path::new(&extracted.abs_path), + artwork_dir, + ) + .await?; + let insert = sqlx::query( "INSERT INTO track ( library_id, folder_id, file_path, file_hash, file_size, file_modified, @@ -1572,6 +1791,96 @@ pub(crate) async fn scan_folder_inner( Ok(summary) } +/// Summary returned by [`rescan_local_artist_images`]. +#[derive(Default, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistImageScanSummary { + /// Number of artists checked (those without an existing `artwork_id`). + pub considered: i64, + /// Number of artists that now have a local sidecar image linked. + pub linked: i64, +} + +/// Walk every `artist` row that has no `artwork_id` and try to resolve a +/// sidecar image from any of their tracks' folders. Cheap on re-runs +/// because already-linked rows are excluded by the SQL filter and we +/// stop at the first track that yields a match. +/// +/// Lets users who scanned their library before this feature shipped pick +/// up `artist.jpg` files without re-importing every folder. +#[tauri::command] +pub async fn rescan_local_artist_images( + state: tauri::State<'_, AppState>, +) -> AppResult { + let pool = state.require_profile_pool().await?; + let profile_id = state.require_profile_id().await?; + let artwork_dir = state.paths.profile_artwork_dir(profile_id); + std::fs::create_dir_all(&artwork_dir)?; + + let va_canon = canonical_name(VARIOUS_ARTISTS_LABEL); + let rows: Vec<(i64, String, String)> = sqlx::query_as( + "SELECT id, name, canonical_name FROM artist + WHERE artwork_id IS NULL + AND canonical_name != ?", + ) + .bind(&va_canon) + .fetch_all(&pool) + .await?; + + let mut summary = ArtistImageScanSummary { + considered: rows.len() as i64, + linked: 0, + }; + + // Batch writes through a single transaction (committed every + // TX_BATCH writes) so SQLite WAL fsyncs once per batch instead of + // once per artist — same pattern as scan_folder_inner. + const TX_BATCH: usize = 200; + let mut tx = pool.begin().await?; + let mut tx_count: usize = 0; + + for (artist_id, _name, canon) in rows { + // Track lookup is a read — run it on the pool so it doesn't + // serialise behind the open write transaction. + let tracks: Vec<(String,)> = sqlx::query_as( + "SELECT t.file_path FROM track t + JOIN track_artist ta ON ta.track_id = t.id + WHERE ta.artist_id = ? AND t.is_available = 1 + LIMIT 16", + ) + .bind(artist_id) + .fetch_all(&pool) + .await?; + + let mut linked = false; + for (path,) in tracks { + if let Some(cover) = extract_artist_image(Path::new(&path), &canon, &artwork_dir) { + link_local_artist_image(&mut tx, artist_id, &cover).await?; + linked = true; + break; + } + } + if linked { + summary.linked += 1; + tx_count += 1; + if tx_count >= TX_BATCH { + tx.commit().await?; + tx = pool.begin().await?; + tx_count = 0; + } + } + } + + tx.commit().await?; + + tracing::info!( + considered = summary.considered, + linked = summary.linked, + "rescan_local_artist_images complete", + ); + Ok(summary) +} + #[cfg(test)] mod tests { use super::*; @@ -1645,6 +1954,78 @@ mod tests { assert!(extract_folder_cover(&track, &artwork_dir).is_none()); } + #[test] + fn artist_image_finds_stem_in_parent_folder() { + // Layout: /// + let dir = tempfile::tempdir().unwrap(); + let artwork_dir = dir.path().join("artwork"); + fs::create_dir_all(&artwork_dir).unwrap(); + let artist_dir = dir.path().join("Daft Punk"); + let album_dir = artist_dir.join("Discovery"); + fs::create_dir_all(&album_dir).unwrap(); + + write_bytes(&artist_dir.join("artist.jpg"), TINY_JPEG); + let track = album_dir.join("01.flac"); + write_bytes(&track, b"x"); + + let cover = extract_artist_image(&track, &canonical_name("Daft Punk"), &artwork_dir) + .expect("artist image found two levels up"); + assert_eq!(cover.source, "folder"); + assert_eq!(cover.format, "jpg"); + } + + #[test] + fn artist_image_matches_canonical_name_stem() { + // Layout: // with `.jpg` beside the album. + let dir = tempfile::tempdir().unwrap(); + let artwork_dir = dir.path().join("artwork"); + fs::create_dir_all(&artwork_dir).unwrap(); + let album_dir = dir.path().join("Discovery"); + fs::create_dir_all(&album_dir).unwrap(); + + write_bytes(&album_dir.join("Daft Punk.png"), TINY_JPEG); + let track = album_dir.join("01.flac"); + write_bytes(&track, b"x"); + + let cover = extract_artist_image(&track, &canonical_name("daft punk"), &artwork_dir) + .expect("canonical-name stem match"); + assert_eq!(cover.format, "png"); + } + + #[test] + fn artist_image_ignores_unrelated_named_image() { + let dir = tempfile::tempdir().unwrap(); + let artwork_dir = dir.path().join("artwork"); + fs::create_dir_all(&artwork_dir).unwrap(); + let album_dir = dir.path().join("Discovery"); + fs::create_dir_all(&album_dir).unwrap(); + + // `cover.jpg` is an album cover, not an artist photo. + write_bytes(&album_dir.join("cover.jpg"), TINY_JPEG); + let track = album_dir.join("01.flac"); + write_bytes(&track, b"x"); + + assert!( + extract_artist_image(&track, &canonical_name("Daft Punk"), &artwork_dir).is_none(), + "should not pick up album cover as artist image", + ); + } + + #[test] + fn artist_image_returns_none_for_empty_canonical() { + let dir = tempfile::tempdir().unwrap(); + let artwork_dir = dir.path().join("artwork"); + fs::create_dir_all(&artwork_dir).unwrap(); + let folder = dir.path().join("album"); + fs::create_dir_all(&folder).unwrap(); + write_bytes(&folder.join("artist.jpg"), TINY_JPEG); + let track = folder.join("01.flac"); + write_bytes(&track, b"x"); + + // Empty canonical → defensive bail-out so we don't match every dir. + assert!(extract_artist_image(&track, "", &artwork_dir).is_none()); + } + #[test] fn folder_cover_writes_hash_addressed_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/commands/tray.rs b/src-tauri/src/commands/tray.rs index d9d6654..4f0a72d 100644 --- a/src-tauri/src/commands/tray.rs +++ b/src-tauri/src/commands/tray.rs @@ -31,10 +31,7 @@ pub struct TrayLabels { } #[tauri::command] -pub fn set_tray_labels( - app: AppHandle, - labels: TrayLabels, -) -> Result<(), String> { +pub fn set_tray_labels(app: AppHandle, labels: TrayLabels) -> Result<(), String> { let Some(items) = app.try_state::>() else { return Ok(()); }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e1d78e..5282f06 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -308,11 +308,9 @@ pub fn run() { // track starts. let play_pause_item = MenuItem::with_id(app, "play_pause", "Play / Pause", true, None::<&str>)?; - let previous_item = - MenuItem::with_id(app, "previous", "Previous", true, None::<&str>)?; + let previous_item = MenuItem::with_id(app, "previous", "Previous", true, None::<&str>)?; let next_item = MenuItem::with_id(app, "next", "Next", true, None::<&str>)?; - let show_item = - MenuItem::with_id(app, "show", "Open WaveFlow", true, None::<&str>)?; + let show_item = MenuItem::with_id(app, "show", "Open WaveFlow", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let menu = Menu::with_items( @@ -424,6 +422,11 @@ pub fn run() { commands::smart_playlists::get_custom_smart_playlist_rules, commands::smart_playlists::preview_custom_smart_playlist, commands::scan::scan_folder, + commands::scan::rescan_local_artist_images, + commands::deezer::search_artists_deezer, + commands::deezer::set_artist_artwork_from_deezer, + commands::deezer::set_artist_artwork_from_file, + commands::deezer::clear_artist_artwork, commands::track::list_tracks, commands::track::get_track, commands::track::search_tracks, diff --git a/src/components/common/ArtistImagePickerModal.tsx b/src/components/common/ArtistImagePickerModal.tsx new file mode 100644 index 0000000..9879e9e --- /dev/null +++ b/src/components/common/ArtistImagePickerModal.tsx @@ -0,0 +1,338 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FolderOpen, + ImageIcon, + Loader2, + Search, + Trash2, + User, +} from "lucide-react"; +import { useModalA11y } from "../../hooks/useModalA11y"; +import { + clearArtistArtwork, + searchArtistsDeezer, + setArtistArtworkFromDeezer, + setArtistArtworkFromFile, + type DeezerArtistLite, +} from "../../lib/tauri/deezer"; +import { pickFile } from "../../lib/tauri/dialog"; + +interface ArtistImagePickerModalProps { + artistId: number; + artistName: string; + /** Whether the artist currently has a non-null artwork_id. Drives the + * visibility of the "remove image" action. */ + hasArtwork: boolean; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +type Tab = "deezer" | "file"; + +/** + * Mirror of [`CoverPickerModal`](./CoverPickerModal.tsx) but bound to the + * `artist` table instead of `album`. Three actions, in order of expected + * frequency: search Deezer, upload a local file, or clear the current + * image so the resolution chain falls back to the Deezer cache. + */ +export function ArtistImagePickerModal({ + artistId, + artistName, + hasArtwork, + isOpen, + onClose, + onSuccess, +}: ArtistImagePickerModalProps) { + const { t } = useTranslation(); + const [tab, setTab] = useState("deezer"); + const [query, setQuery] = useState(artistName); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const [error, setError] = useState(null); + const debounceRef = useRef(null); + // Monotonic counter so a slow earlier response can't overwrite the + // results of a newer query — the user typing fast was producing + // visible "old results flicker" when the network was slow. + const requestIdRef = useRef(0); + const dialogRef = useModalA11y(isOpen, onClose); + + useEffect(() => { + if (!isOpen) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setQuery(artistName); + setResults([]); + setError(null); + setTab("deezer"); + } + }, [isOpen, artistName]); + + useEffect(() => { + if (!isOpen || tab !== "deezer") { + // Abandoning the search — bump the request id so any in-flight + // Deezer call's `.then()` is treated as stale and skipped. + requestIdRef.current++; + return; + } + if (debounceRef.current != null) { + window.clearTimeout(debounceRef.current); + } + const trimmed = query.trim(); + if (trimmed.length < 2) { + requestIdRef.current++; + // eslint-disable-next-line react-hooks/set-state-in-effect + setResults([]); + setIsSearching(false); + setError(null); + return; + } + debounceRef.current = window.setTimeout(() => { + const requestId = ++requestIdRef.current; + setIsSearching(true); + setError(null); + searchArtistsDeezer(trimmed) + .then((res) => { + if (requestId !== requestIdRef.current) return; + setResults(res); + }) + .catch((err) => { + if (requestId !== requestIdRef.current) return; + console.error("[ArtistImagePickerModal] search failed", err); + setError(String(err)); + }) + .finally(() => { + if (requestId !== requestIdRef.current) return; + setIsSearching(false); + }); + }, 300); + return () => { + if (debounceRef.current != null) { + window.clearTimeout(debounceRef.current); + } + }; + }, [query, tab, isOpen]); + + if (!isOpen) return null; + + const handlePickDeezer = async (hit: DeezerArtistLite) => { + if (isApplying) return; + setIsApplying(true); + setError(null); + try { + await setArtistArtworkFromDeezer(artistId, hit.deezer_id); + onSuccess(); + onClose(); + } catch (err) { + console.error("[ArtistImagePickerModal] set deezer image failed", err); + setError(String(err)); + } finally { + setIsApplying(false); + } + }; + + const handlePickFile = async () => { + if (isApplying) return; + try { + const path = await pickFile( + ["jpg", "jpeg", "png", "webp"], + t("artistImagePicker.title"), + ); + if (!path) return; + setIsApplying(true); + setError(null); + await setArtistArtworkFromFile(artistId, path); + onSuccess(); + onClose(); + } catch (err) { + console.error("[ArtistImagePickerModal] set file image failed", err); + setError(String(err)); + } finally { + setIsApplying(false); + } + }; + + const handleClear = async () => { + if (isApplying) return; + setIsApplying(true); + setError(null); + try { + await clearArtistArtwork(artistId); + onSuccess(); + onClose(); + } catch (err) { + console.error("[ArtistImagePickerModal] clear image failed", err); + setError(String(err)); + } finally { + setIsApplying(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +

+ {t("artistImagePicker.title")} +

+ +
+ + +
+ + {error &&
{error}
} + + {tab === "deezer" ? ( +
+
+ + setQuery(e.target.value)} + placeholder={t("library.searchDeezer")} + autoFocus + className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-zinc-50 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" + /> + {isSearching && ( + + )} +
+
+ {results.length === 0 ? ( +
+ {query.trim().length < 2 + ? t("library.searchDeezer") + : isSearching + ? "..." + : ""} +
+ ) : ( +
+ {results.map((hit) => ( + + ))} +
+ )} +
+
+ ) : ( +
+
+ +
+ +
+ )} + +
+ {hasArtwork ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/src/components/common/LyricsEditorModal.tsx b/src/components/common/LyricsEditorModal.tsx index 0d5c9fe..24f3b1c 100644 --- a/src/components/common/LyricsEditorModal.tsx +++ b/src/components/common/LyricsEditorModal.tsx @@ -166,7 +166,10 @@ export function LyricsEditorModal({ text: w.text, })); const cursor = words - ? Math.min(words.length, words.findIndex((w) => w.timeMs < 0)) + ? Math.min( + words.length, + words.findIndex((w) => w.timeMs < 0), + ) : undefined; return { id: nextIdRef.current++, @@ -668,9 +671,8 @@ export function LyricsEditorModal({

{granularity === "word" ? t("lyricsEditor.captureHintWord") - : t("lyricsEditor.captureHint")} - {" "}· {captured}/{syncedRows.length}{" "} - {t("lyricsEditor.lines")} + : t("lyricsEditor.captureHint")}{" "} + · {captured}/{syncedRows.length} {t("lyricsEditor.lines")}

{/* Global timestamp shift — applied to every captured row @@ -866,72 +868,72 @@ function SyncedEditor({ }`} onFocus={() => onActivate(idx)} > -
- - - onUpdateText(row.id, e.target.value)} - onFocus={() => onActivate(idx)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - onInsertBelow(row.id); - // Defer so the new row exists before we activate it. - setTimeout(() => onActivate(idx + 1), 0); +
+ + - - -
+ className={`shrink-0 font-mono text-xs px-2 py-1 rounded w-20 text-center transition-colors ${ + captured + ? shifted + ? "bg-pink-100 dark:bg-pink-900/40 text-pink-600 dark:text-pink-300 hover:bg-pink-200 dark:hover:bg-pink-900/60 italic" + : "bg-zinc-200 dark:bg-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-600" + : "bg-zinc-100 dark:bg-zinc-800 text-zinc-400 dark:text-zinc-500 hover:text-pink-500" + }`} + > + {captured ? formatLrcTimestamp(previewMs) : "--:--.--"} + + onUpdateText(row.id, e.target.value)} + onFocus={() => onActivate(idx)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onInsertBelow(row.id); + // Defer so the new row exists before we activate it. + setTimeout(() => onActivate(idx + 1), 0); + } + }} + placeholder={t("lyricsEditor.linePlaceholder")} + className="flex-1 bg-transparent text-sm focus:outline-none px-2 py-1" + /> + + + +
{showWordChips && (
{row.words!.map((w, wi) => { diff --git a/src/components/layout/LyricsPanel.tsx b/src/components/layout/LyricsPanel.tsx index 90de1a5..1c96eeb 100644 --- a/src/components/layout/LyricsPanel.tsx +++ b/src/components/layout/LyricsPanel.tsx @@ -324,14 +324,16 @@ export function LyricsPanel() { {payload ? sourceLabel(payload.source, t) : ""} - {payload && (payload.format === "enhanced_lrc" || payload.format === "ttml") && ( - - {payload.format === "ttml" ? "TTML" : "WORD"} - - )} + {payload && + (payload.format === "enhanced_lrc" || + payload.format === "ttml") && ( + + {payload.format === "ttml" ? "TTML" : "WORD"} + + )}
- )} +
@@ -487,6 +508,15 @@ export function ArtistDetailView({ isOpen={isLightboxOpen} onClose={() => setIsLightboxOpen(false)} /> + + setIsImagePickerOpen(false)} + onSuccess={() => setEditRefetch((k) => k + 1)} + />
); } diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index e93dfaa..fcf6fec 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -98,7 +98,10 @@ import { listen } from "@tauri-apps/api/event"; import { useLibrary } from "../../hooks/useLibrary"; import { useProfile } from "../../hooks/useProfile"; import { invoke } from "@tauri-apps/api/core"; -import { regenerateThumbnails } from "../../lib/tauri/library"; +import { + regenerateThumbnails, + rescanLocalArtistImages, +} from "../../lib/tauri/library"; import { getAutoStart, getMinimizeToTray, @@ -647,6 +650,33 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { }; }, []); + // Rescan local sidecar `artist.jpg` files (see scan.rs::extract_artist_image). + const [isRescanningLocalArtists, setIsRescanningLocalArtists] = + useState(false); + const [localArtistRescanStatus, setLocalArtistRescanStatus] = useState< + string | null + >(null); + + const handleRescanLocalArtistImages = useCallback(async () => { + if (isRescanningLocalArtists) return; + setIsRescanningLocalArtists(true); + setLocalArtistRescanStatus(null); + try { + const summary = await rescanLocalArtistImages(); + setLocalArtistRescanStatus( + t("settings.localArtistImages.done", { + linked: summary.linked, + considered: summary.considered, + }), + ); + } catch (err) { + console.error("[SettingsView] rescan local artist images failed", err); + } finally { + setIsRescanningLocalArtists(false); + window.setTimeout(() => setLocalArtistRescanStatus(null), 5000); + } + }, [isRescanningLocalArtists, t]); + const handleFetchMissingArtistPictures = async () => { if (isFetchingArtists) return; setIsFetchingArtists(true); @@ -2263,6 +2293,38 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { )}
+
+
+
+ +
+
.jpg) بجانب الموسيقى ويستخدمه كصورة للفنان.", + "action": "فحص", + "done": "تم ربط {{linked}} من أصل {{considered}} فنانًا بصورة محلية" + }, "dataFolder": { "title": "ملف البيانات", "subtitle": "افتح المجلد الذي يحتوي على قاعدة البيانات والأغلفة", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 12c59d3..b12c404 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -845,6 +845,13 @@ "notInLibrary": "Nicht in deiner Bibliothek" } }, + "artistImagePicker": { + "title": "Künstlerbild ändern", + "editAria": "Künstlerfoto ändern", + "removeAction": "Bild entfernen", + "fansCount_one": "{{display}} Fan", + "fansCount_other": "{{display}} Fans" + }, "genreDetail": { "badge": "Genre", "playAll": "Alle abspielen", @@ -1216,6 +1223,12 @@ "action": "Wiederherstellen", "progress": "{{current}}/{{total}} verarbeitet" }, + "localArtistImages": { + "title": "Lokale Künstlerbilder", + "subtitle": "Sucht nach einer artist.jpg-Datei (oder .jpg) neben der Musik und nutzt sie als Künstlerfoto.", + "action": "Scannen", + "done": "{{linked}} von {{considered}} Künstlern mit lokalem Bild verknüpft" + }, "dataFolder": { "title": "Datenblatt", "subtitle": "Öffne den Ordner, der die Datenbank und die Cover enthält", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b960886..a9b5415 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -853,6 +853,13 @@ "notInLibrary": "Not in your library" } }, + "artistImagePicker": { + "title": "Change artist image", + "editAria": "Change artist photo", + "removeAction": "Remove image", + "fansCount_one": "{{display}} fan", + "fansCount_other": "{{display}} fans" + }, "genreDetail": { "badge": "Genre", "playAll": "Play all", @@ -1254,6 +1261,12 @@ "action": "Recover", "progress": "{{current}}/{{total}} processed" }, + "localArtistImages": { + "title": "Local artist images", + "subtitle": "Look for an artist.jpg (or .jpg) next to your music and use it as the artist photo.", + "action": "Scan", + "done": "{{linked}} of {{considered}} artists linked to a local image" + }, "profileIo": { "title": "Profile backup", "subtitle": "Export or import a full profile (playlists, likes, stats, EQ, shortcuts) as a single .waveflow file.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 82114e7..b002f83 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -845,6 +845,13 @@ "notInLibrary": "Fuera de tu biblioteca" } }, + "artistImagePicker": { + "title": "Cambiar imagen del artista", + "editAria": "Cambiar la foto del artista", + "removeAction": "Eliminar imagen", + "fansCount_one": "{{display}} fan", + "fansCount_other": "{{display}} fans" + }, "genreDetail": { "badge": "Género", "playAll": "Reproducir todo", @@ -1216,6 +1223,12 @@ "action": "Recuperar", "progress": "{{current}}/{{total}} procesados" }, + "localArtistImages": { + "title": "Imágenes locales de artistas", + "subtitle": "Busca un archivo artist.jpg (o .jpg) junto a la música y lo usa como foto del artista.", + "action": "Escanear", + "done": "{{linked}} de {{considered}} artistas vinculados a una imagen local" + }, "dataFolder": { "title": "Archivo de datos", "subtitle": "Abre la carpeta que contiene la base de datos y las carátulas", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b557cd4..0a40c51 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -853,6 +853,13 @@ "notInLibrary": "Pas dans votre bibliothèque" } }, + "artistImagePicker": { + "title": "Modifier l'image de l'artiste", + "editAria": "Modifier la photo de l'artiste", + "removeAction": "Supprimer l'image", + "fansCount_one": "{{display}} fan", + "fansCount_other": "{{display}} fans" + }, "genreDetail": { "badge": "Genre", "playAll": "Tout lire", @@ -1254,6 +1261,12 @@ "action": "Récupérer", "progress": "{{current}}/{{total}} traités" }, + "localArtistImages": { + "title": "Images artistes locales", + "subtitle": "Recherche un fichier artist.jpg (ou .jpg) à côté de la musique et l'utilise comme photo d'artiste.", + "action": "Scanner", + "done": "{{linked}} sur {{considered}} artistes liés à une image locale" + }, "profileIo": { "title": "Sauvegarde du profil", "subtitle": "Exporte ou importe un profil entier (playlists, j'aime, statistiques, EQ, raccourcis) sous forme d'un fichier .waveflow.", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 014e44e..43a5a1c 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -845,6 +845,13 @@ "notInLibrary": "आपकी लाइब्रेरी में नहीं" } }, + "artistImagePicker": { + "title": "कलाकार की छवि बदलें", + "editAria": "कलाकार की तस्वीर बदलें", + "removeAction": "छवि हटाएँ", + "fansCount_one": "{{display}} प्रशंसक", + "fansCount_other": "{{display}} प्रशंसक" + }, "genreDetail": { "badge": "शैली", "playAll": "सभी चलाएँ", @@ -1216,6 +1223,12 @@ "action": "पुनःप्राप्त करें", "progress": "{{current}}/{{total}} संसाधित" }, + "localArtistImages": { + "title": "स्थानीय कलाकार छवियाँ", + "subtitle": "संगीत के बगल में artist.jpg (या <नाम>.jpg) फ़ाइल खोजें और उसे कलाकार की तस्वीर के रूप में उपयोग करें।", + "action": "स्कैन", + "done": "{{considered}} में से {{linked}} कलाकार स्थानीय छवि से जुड़े" + }, "dataFolder": { "title": "डेटा शीट", "subtitle": "डेटाबेस और कवर वाले फ़ोल्डर को खोलें।", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 99bad68..3e1b898 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -845,6 +845,12 @@ "notInLibrary": "Tidak di pustaka Anda" } }, + "artistImagePicker": { + "title": "Ubah gambar artis", + "editAria": "Ubah foto artis", + "removeAction": "Hapus gambar", + "fansCount_other": "{{display}} penggemar" + }, "genreDetail": { "badge": "Genre", "playAll": "Putar semua", @@ -1216,6 +1222,12 @@ "action": "Ambil", "progress": "{{current}}/{{total}} diproses" }, + "localArtistImages": { + "title": "Gambar artis lokal", + "subtitle": "Mencari berkas artist.jpg (atau .jpg) di sebelah musik dan menggunakannya sebagai foto artis.", + "action": "Pindai", + "done": "{{linked}} dari {{considered}} artis terhubung ke gambar lokal" + }, "dataFolder": { "title": "Folder data", "subtitle": "Buka folder yang berisi basis data dan gambar sampul", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 0aff270..8cf0a39 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -845,6 +845,13 @@ "notInLibrary": "Non nella tua libreria" } }, + "artistImagePicker": { + "title": "Cambia immagine dell'artista", + "editAria": "Cambia la foto dell'artista", + "removeAction": "Rimuovi immagine", + "fansCount_one": "{{display}} fan", + "fansCount_other": "{{display}} fan" + }, "genreDetail": { "badge": "Genere", "playAll": "Riproduci tutto", @@ -1216,6 +1223,12 @@ "action": "Recuperare", "progress": "{{current}}/{{total}} elaborati" }, + "localArtistImages": { + "title": "Immagini artisti locali", + "subtitle": "Cerca un file artist.jpg (o .jpg) accanto alla musica e lo usa come foto dell'artista.", + "action": "Scansiona", + "done": "{{linked}} su {{considered}} artisti collegati a un'immagine locale" + }, "dataFolder": { "title": "Fascicolo dei dati", "subtitle": "Aprire la cartella contenente il database e le copertine", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0815bcb..ade8c17 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -845,6 +845,12 @@ "notInLibrary": "ライブラリにない" } }, + "artistImagePicker": { + "title": "アーティスト画像を変更", + "editAria": "アーティスト写真を変更", + "removeAction": "画像を削除", + "fansCount_other": "{{display}} 人のファン" + }, "genreDetail": { "badge": "ジャンル", "playAll": "すべて再生", @@ -1216,6 +1222,12 @@ "action": "復元する", "progress": "{{current}}/{{total}} 処理済み" }, + "localArtistImages": { + "title": "ローカルアーティスト画像", + "subtitle": "音楽ファイルの隣にある artist.jpg(または <名前>.jpg)を探し、アーティスト写真として利用します。", + "action": "スキャン", + "done": "{{considered}} 人中 {{linked}} 人のアーティストにローカル画像をリンクしました" + }, "dataFolder": { "title": "データファイル", "subtitle": "データベースとカバー画像が入っているフォルダを開く", diff --git a/src/i18n/locales/kr.json b/src/i18n/locales/kr.json index 678f2b8..e9dd7ce 100644 --- a/src/i18n/locales/kr.json +++ b/src/i18n/locales/kr.json @@ -845,6 +845,12 @@ "notInLibrary": "라이브러리에 없음" } }, + "artistImagePicker": { + "title": "아티스트 이미지 변경", + "editAria": "아티스트 사진 변경", + "removeAction": "이미지 제거", + "fansCount_other": "{{display}} 팬" + }, "genreDetail": { "badge": "장르", "playAll": "모두 재생", @@ -1216,6 +1222,12 @@ "action": "복구", "progress": "{{current}}/{{total}} 처리됨" }, + "localArtistImages": { + "title": "로컬 아티스트 이미지", + "subtitle": "음악 옆에 있는 artist.jpg (또는 <이름>.jpg) 파일을 찾아 아티스트 사진으로 사용합니다.", + "action": "스캔", + "done": "{{considered}}명 중 {{linked}}명의 아티스트에 로컬 이미지를 연결했습니다" + }, "dataFolder": { "title": "데이터 세트", "subtitle": "데이터베이스와 표지가 들어 있는 폴더를 엽니다", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 2c78f90..3863717 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -845,6 +845,13 @@ "notInLibrary": "Niet in je bibliotheek" } }, + "artistImagePicker": { + "title": "Artiestafbeelding wijzigen", + "editAria": "Artiestfoto wijzigen", + "removeAction": "Afbeelding verwijderen", + "fansCount_one": "{{display}} fan", + "fansCount_other": "{{display}} fans" + }, "genreDetail": { "badge": "Genre", "playAll": "Alles afspelen", @@ -1216,6 +1223,12 @@ "action": "Terugwinnen", "progress": "{{current}}/{{total}} verwerkt" }, + "localArtistImages": { + "title": "Lokale artiestafbeeldingen", + "subtitle": "Zoekt naar een artist.jpg (of .jpg) naast de muziek en gebruikt die als artiestfoto.", + "action": "Scannen", + "done": "{{linked}} van {{considered}} artiesten gekoppeld aan een lokale afbeelding" + }, "dataFolder": { "title": "Gegevensbestand", "subtitle": "Open de map met de database en de omslagen", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index de786da..009bf0a 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -845,6 +845,13 @@ "notInLibrary": "Fora da sua biblioteca" } }, + "artistImagePicker": { + "title": "Alterar imagem do artista", + "editAria": "Alterar foto do artista", + "removeAction": "Remover imagem", + "fansCount_one": "{{display}} fã", + "fansCount_other": "{{display}} fãs" + }, "genreDetail": { "badge": "Gênero", "playAll": "Reproduzir tudo", @@ -1216,6 +1223,12 @@ "action": "Recuperar", "progress": "{{current}}/{{total}} processados" }, + "localArtistImages": { + "title": "Imagens locais de artistas", + "subtitle": "Procura um arquivo artist.jpg (ou .jpg) ao lado da música e o usa como foto do artista.", + "action": "Escanear", + "done": "{{linked}} de {{considered}} artistas vinculados a uma imagem local" + }, "dataFolder": { "title": "Pasta de dados", "subtitle": "Abrir a pasta que contém o banco de dados e as capas", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a30fe22..591fc3d 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -845,6 +845,13 @@ "notInLibrary": "Fora da sua biblioteca" } }, + "artistImagePicker": { + "title": "Alterar imagem do artista", + "editAria": "Alterar foto do artista", + "removeAction": "Remover imagem", + "fansCount_one": "{{display}} fã", + "fansCount_other": "{{display}} fãs" + }, "genreDetail": { "badge": "Género", "playAll": "Reproduzir tudo", @@ -1216,6 +1223,12 @@ "action": "Recuperar", "progress": "{{current}}/{{total}} processados" }, + "localArtistImages": { + "title": "Imagens locais de artistas", + "subtitle": "Procura um ficheiro artist.jpg (ou .jpg) junto à música e usa-o como foto do artista.", + "action": "Procurar", + "done": "{{linked}} de {{considered}} artistas associados a uma imagem local" + }, "dataFolder": { "title": "Pasta de dados", "subtitle": "Abrir a pasta que contém a base de dados e as capas", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d0b0139..06ac4ca 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -845,6 +845,15 @@ "notInLibrary": "Нет в вашей библиотеке" } }, + "artistImagePicker": { + "title": "Изменить изображение исполнителя", + "editAria": "Изменить фото исполнителя", + "removeAction": "Удалить изображение", + "fansCount_one": "{{display}} поклонник", + "fansCount_few": "{{display}} поклонника", + "fansCount_many": "{{display}} поклонников", + "fansCount_other": "{{display}} поклонников" + }, "genreDetail": { "badge": "Жанр", "playAll": "Воспроизвести всё", @@ -1216,6 +1225,12 @@ "action": "Загрузить", "progress": "{{current}}/{{total}} обработано" }, + "localArtistImages": { + "title": "Локальные изображения исполнителей", + "subtitle": "Ищет файл artist.jpg (или <имя>.jpg) рядом с музыкой и использует его как фото исполнителя.", + "action": "Сканировать", + "done": "Связано {{linked}} из {{considered}} исполнителей с локальным изображением" + }, "dataFolder": { "title": "Папка «Данные»", "subtitle": "Откройте папку, содержащую базу данных и обложки", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 4d542cf..42ebf0d 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -845,6 +845,12 @@ "notInLibrary": "Kütüphanenizde değil" } }, + "artistImagePicker": { + "title": "Sanatçı görselini değiştir", + "editAria": "Sanatçı fotoğrafını değiştir", + "removeAction": "Görseli kaldır", + "fansCount_other": "{{display}} hayran" + }, "genreDetail": { "badge": "Tür", "playAll": "Tümünü çal", @@ -1216,6 +1222,12 @@ "action": "Getir", "progress": "{{current}}/{{total}} işlendi" }, + "localArtistImages": { + "title": "Yerel sanatçı görselleri", + "subtitle": "Müziğin yanındaki artist.jpg (veya .jpg) dosyasını bulup sanatçı fotoğrafı olarak kullanır.", + "action": "Tara", + "done": "{{considered}} sanatçıdan {{linked}} tanesi yerel görsele bağlandı" + }, "dataFolder": { "title": "Veri klasörü", "subtitle": "Veritabanını ve kapak resmini içeren klasörü açın", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e5a0bea..1716397 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -845,6 +845,12 @@ "notInLibrary": "不在你的曲库中" } }, + "artistImagePicker": { + "title": "更改艺人图片", + "editAria": "更改艺人照片", + "removeAction": "移除图片", + "fansCount_other": "{{display}} 粉丝" + }, "genreDetail": { "badge": "流派", "playAll": "播放全部", @@ -1216,6 +1222,12 @@ "action": "恢复", "progress": "已处理 {{current}}/{{total}}" }, + "localArtistImages": { + "title": "本地艺人图片", + "subtitle": "在音乐旁查找 artist.jpg(或 <名称>.jpg)文件,并将其用作艺人头像。", + "action": "扫描", + "done": "已为 {{considered}} 位艺人中的 {{linked}} 位关联本地图片" + }, "dataFolder": { "title": "数据文件", "subtitle": "打开包含数据库和封面图片的文件夹", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index ca81c95..c1b713b 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -845,6 +845,12 @@ "notInLibrary": "不在你的曲庫中" } }, + "artistImagePicker": { + "title": "更改藝人圖片", + "editAria": "更改藝人照片", + "removeAction": "移除圖片", + "fansCount_other": "{{display}} 粉絲" + }, "genreDetail": { "badge": "曲風", "playAll": "播放全部", @@ -1216,6 +1222,12 @@ "action": "恢復", "progress": "已處理 {{current}}/{{total}}" }, + "localArtistImages": { + "title": "本地藝人圖片", + "subtitle": "在音樂旁尋找 artist.jpg(或 <名稱>.jpg)檔案,並將其用作藝人照片。", + "action": "掃描", + "done": "已為 {{considered}} 位藝人中的 {{linked}} 位連結本地圖片" + }, "dataFolder": { "title": "資料檔案", "subtitle": "開啟包含資料庫和封面圖檔的資料夾", diff --git a/src/lib/tauri/deezer.ts b/src/lib/tauri/deezer.ts index 411849a..30a9097 100644 --- a/src/lib/tauri/deezer.ts +++ b/src/lib/tauri/deezer.ts @@ -35,3 +35,37 @@ export function batchFetchMissingAlbumCovers(): Promise { export function batchFetchMissingArtistPictures(): Promise { return invoke("batch_fetch_missing_artist_pictures"); } + +export interface DeezerArtistLite { + deezer_id: number; + name: string; + picture_url: string | null; + nb_fan: number | null; +} + +export function searchArtistsDeezer( + query: string, +): Promise { + return invoke("search_artists_deezer", { query }); +} + +export function setArtistArtworkFromDeezer( + artistId: number, + deezerArtistId: number, +): Promise { + return invoke("set_artist_artwork_from_deezer", { + artistId, + deezerArtistId, + }); +} + +export function setArtistArtworkFromFile( + artistId: number, + filePath: string, +): Promise { + return invoke("set_artist_artwork_from_file", { artistId, filePath }); +} + +export function clearArtistArtwork(artistId: number): Promise { + return invoke("clear_artist_artwork", { artistId }); +} diff --git a/src/lib/tauri/library.ts b/src/lib/tauri/library.ts index bc60a90..34fb531 100644 --- a/src/lib/tauri/library.ts +++ b/src/lib/tauri/library.ts @@ -96,6 +96,25 @@ export function scanFolder(folderId: number): Promise { return invoke("scan_folder", { folderId }); } +/** Outcome of {@link rescanLocalArtistImages}. */ +export interface ArtistImageScanSummary { + /** Artists whose `artwork_id` was NULL when the rescan started. */ + considered: number; + /** Artists that now have a sidecar image linked. */ + linked: number; +} + +/** + * Walk every artist row that still has no local artwork and try to + * resolve a sidecar `artist.jpg` (or `.jpg`) from any + * folder along their tracks' paths. Lets libraries scanned before the + * local-artist-image feature shipped pick up the sidecars without + * re-importing every folder. + */ +export function rescanLocalArtistImages(): Promise { + return invoke("rescan_local_artist_images"); +} + /** * Per-library folder row used by the folder management UI: just the * raw `library_folder` columns the user can see and act on (path, diff --git a/src/lib/tauri/lyrics.ts b/src/lib/tauri/lyrics.ts index e9baa8c..e4e9849 100644 --- a/src/lib/tauri/lyrics.ts +++ b/src/lib/tauri/lyrics.ts @@ -286,7 +286,8 @@ export function parseEnhancedLrc(content: string): LyricsLine[] { for (let i = 0; i < wordStamps.length; i += 1) { const start = wordStamps[i].at + matchedStampLength(body, wordStamps[i].at); - const end = i + 1 < wordStamps.length ? wordStamps[i + 1].at : body.length; + const end = + i + 1 < wordStamps.length ? wordStamps[i + 1].at : body.length; built.push({ timeMs: wordStamps[i].timeMs, endMs: i + 1 < wordStamps.length ? wordStamps[i + 1].timeMs : -1, @@ -297,7 +298,10 @@ export function parseEnhancedLrc(content: string): LyricsLine[] { // Drop trailing empty segments without timing (artefact of a // trailing space after the last stamp). const words = built.filter((w) => w.text.length > 0 || w.timeMs >= 0); - const text = words.map((w) => w.text).join("").trim(); + const text = words + .map((w) => w.text) + .join("") + .trim(); // Deep-clone the words array per line entry so `fillEndTimestamps` // can mutate each independently. For prefix-bearing lines, the @@ -374,7 +378,10 @@ export function parseTtml(content: string): LyricsLine[] { }); } if (words.length === 0) words = undefined; - text = (words ?? []).map((w) => w.text).join("").trim(); + text = (words ?? []) + .map((w) => w.text) + .join("") + .trim(); } else { text = (p.textContent ?? "").replace(/\s+/g, " ").trim(); } @@ -431,11 +438,7 @@ function parseTtmlTime(value: string | null): number { const h = Number(hh); const m = Number(mm); const sec = Number(ss); - if ( - Number.isFinite(h) && - Number.isFinite(m) && - Number.isFinite(sec) - ) { + if (Number.isFinite(h) && Number.isFinite(m) && Number.isFinite(sec)) { return Math.round(h * 3_600_000 + m * 60_000 + sec * 1000); } return -1; diff --git a/src/main.tsx b/src/main.tsx index 1b243ef..b489400 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,9 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { getCurrentWindow, Window as TauriWindow } from "@tauri-apps/api/window"; +import { + getCurrentWindow, + Window as TauriWindow, +} from "@tauri-apps/api/window"; import App from "./App"; import { MiniPlayerApp } from "./MiniPlayerApp"; import "./app.css";