Compare commits
38 Commits
6fff93fe45
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8df1eb48 | |||
| 085f58e40f | |||
| 72bc53e495 | |||
| 651d87af4c | |||
| df4967dd55 | |||
| 1bffafad44 | |||
| e19c25e94b | |||
| 7f719bec11 | |||
| 8d773f8188 | |||
| af4f8ce77f | |||
| a4f5bdd7a8 | |||
| 456f854863 | |||
| 3118d969c6 | |||
| 17b6f7958e | |||
| cba49ce411 | |||
| 369ea9df02 | |||
| ca5f79b23a | |||
| 8fb27b1acd | |||
| 25ce2d676e | |||
| 38db835973 | |||
| c30b205d9c | |||
| 7b84bc32df | |||
| 96a01bdced | |||
| e4586f6497 | |||
| f4ef13ec0d | |||
| 05acc3483c | |||
| efaa9f02b8 | |||
| e535fdb4bc | |||
| 8b3989e71f | |||
| 9e75322a43 | |||
| a602ee4bbd | |||
| 9333e55095 | |||
| e5c8ce1c30 | |||
| 7c64818db1 | |||
| 480aa5859b | |||
| 26c465118b | |||
| fc2b987f63 | |||
| a7fc6e8d5d |
82
CHANGELOG.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-10-05
|
||||||
|
|
||||||
|
### 🎉 First Alpha Release
|
||||||
|
|
||||||
|
This is the very first usable build of Shark! - a retro Windows 98-styled music player and downloader. It's rough around the edges and likely contains uncaught bugs, but the core functionality is working.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
**Local Library Management**
|
||||||
|
- SQLite-backed library cache with automatic scanning
|
||||||
|
- Album views with cover art
|
||||||
|
- Track listing and playback
|
||||||
|
|
||||||
|
**Music Player**
|
||||||
|
- Now playing panel with playback controls
|
||||||
|
- Volume control with triangle slider
|
||||||
|
- Queue tracks from context menus
|
||||||
|
- Can show synced lyrics when the window is enlarged
|
||||||
|
- Can be hidden or shown
|
||||||
|
|
||||||
|
**Deezer Integration**
|
||||||
|
- Browse and download user favorites (tracks, albums, playlists)
|
||||||
|
- Search for tracks, albums, and artists
|
||||||
|
- Playlist caching with SQLite
|
||||||
|
- Alternative format fallback for unavailable lossless content
|
||||||
|
|
||||||
|
**Download Queue**
|
||||||
|
- Track and playlist downloads progress
|
||||||
|
- Automatic audio decryption
|
||||||
|
- Metadata tagging (ID3 for MP3, FLAC tags)
|
||||||
|
- Cover art embedding
|
||||||
|
- Existence checks to avoid re-downloading
|
||||||
|
|
||||||
|
**Lyrics**
|
||||||
|
- Automatic lyrics fetching during downloads
|
||||||
|
- LRCLIB integration for manual lyric fetching
|
||||||
|
- Scan library for missing lyrics
|
||||||
|
|
||||||
|
**UI/UX**
|
||||||
|
- Windows 98 (ish) dark theme throughout
|
||||||
|
- Custom window decorations for OS parity
|
||||||
|
- Bottom status bar for notifications
|
||||||
|
- Onboarding prompts for folder selection
|
||||||
|
- Double-click interactions
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- This is an early version - expect bugs and rough edges
|
||||||
|
- Limited error handling in some edge cases
|
||||||
|
- UI polish needed in various areas
|
||||||
|
- Some features may not work as expected
|
||||||
|
- Breaking changes likely in future releases
|
||||||
|
- Lack of loading state means pages initially show no data
|
||||||
|
- No dynamic routes for artists (yet)
|
||||||
|
|
||||||
|
### Known Bugs
|
||||||
|
|
||||||
|
- Context menus show inappropriate options sometimes
|
||||||
|
- Now Playing section causes layout jitter on nav
|
||||||
|
- Now Playing section causes title bar vertical shift
|
||||||
|
- Batch lyrics downloading is less reliable than individual
|
||||||
|
- Track scrubbing causes lyrics desync
|
||||||
|
- Stopping (not pausing) a track and starting the same one resumes progress
|
||||||
|
- Album year is not populated in Library table
|
||||||
|
- Not all tables clip text overflow in certain columns
|
||||||
|
- Album fetching edge cases (`Doja Cat` and `K/DA`)
|
||||||
|
- Download interruptions don't always clean up temp files
|
||||||
|
- LRCLIB service page indexes temp folder
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Built with Tauri 2 + SvelteKit 2 + Svelte 5
|
||||||
|
- Rust backend for audio processing and decryption
|
||||||
|
- SQLite for caching and library management
|
||||||
|
- File-based storage (FLAC/MP3 + metadata)
|
||||||
|
- Playlists are stored as .m3u8 files with relative paths to tracks
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
This release represents 5 days of development and ~80 commits. The app is functional enough for basic use but still has plenty of rough edges. Use at your own risk and expect possible breaking changes as development continues.
|
||||||
6
bun.lock
@@ -4,12 +4,14 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "shark",
|
"name": "shark",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fabianlars/tauri-plugin-oauth": "2",
|
||||||
"@noble/ciphers": "^2.0.1",
|
"@noble/ciphers": "^2.0.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-os": "~2",
|
||||||
"@tauri-apps/plugin-process": "~2",
|
"@tauri-apps/plugin-process": "~2",
|
||||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
@@ -83,6 +85,8 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
||||||
|
|
||||||
|
"@fabianlars/tauri-plugin-oauth": ["@fabianlars/tauri-plugin-oauth@2.0.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.3" } }, "sha512-I1s08ZXrsFuYfNWusAcpLyiCfr5TCvaBrRuKfTG+XQrcaqnAcwjdWH0U5J9QWuMDLwCUMnVxdobtMJzPR8raxQ=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -187,6 +191,8 @@
|
|||||||
|
|
||||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="],
|
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="],
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ty5V8XDUIFbSnrk3zsFoP3kzN+vAufYzalJSlmrVhQTImIZa1aL1a03bOaP2vuBvfR+WDRC6NgV2xBl8G07d+w=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ=="],
|
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-sql": ["@tauri-apps/plugin-sql@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-JYwIocfsLaDWa41LMiZWuzts7yCJR+EpZPRmgpO7Gd7XiAS9S67dKz306j/k/d9XntB0YopMRBol2OIWMschuA=="],
|
"@tauri-apps/plugin-sql": ["@tauri-apps/plugin-sql@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-JYwIocfsLaDWa41LMiZWuzts7yCJR+EpZPRmgpO7Gd7XiAS9S67dKz306j/k/d9XntB0YopMRBol2OIWMschuA=="],
|
||||||
|
|||||||
@@ -11,14 +11,17 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fabianlars/tauri-plugin-oauth": "2",
|
||||||
"@noble/ciphers": "^2.0.1",
|
"@noble/ciphers": "^2.0.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-os": "~2",
|
||||||
"@tauri-apps/plugin-process": "~2",
|
"@tauri-apps/plugin-process": "~2",
|
||||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
|
|||||||
214
src-tauri/Cargo.lock
generated
@@ -8,9 +8,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"blowfish",
|
"blowfish",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"futures-util",
|
||||||
"id3",
|
"id3",
|
||||||
"md5",
|
"md5",
|
||||||
"metaflac",
|
"metaflac",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -18,10 +20,15 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
|
"tauri-plugin-oauth",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-os",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-sql",
|
"tauri-plugin-sql",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
|
"tokio",
|
||||||
|
"unicode-normalization",
|
||||||
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -659,7 +666,7 @@ dependencies = [
|
|||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1192,6 +1199,15 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1199,7 +1215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1213,6 +1229,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1453,6 +1475,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gethostname"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.1.16"
|
version = "0.1.16"
|
||||||
@@ -1844,6 +1876,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.17"
|
version = "0.1.17"
|
||||||
@@ -2464,6 +2512,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2858,6 +2923,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.73"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.109"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2874,6 +2983,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "os_info"
|
||||||
|
version = "3.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"plist",
|
||||||
|
"serde",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -3579,10 +3700,12 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3593,6 +3716,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -3755,6 +3879,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3818,6 +3951,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -4116,7 +4272,7 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -4479,6 +4635,15 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sys-locale"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -4767,6 +4932,21 @@ dependencies = [
|
|||||||
"urlpattern",
|
"urlpattern",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-oauth"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eda564acdb23185caf700f89dd6e5d4540225d6a991516b2cad0cbcf27e4dcd3"
|
||||||
|
dependencies = [
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -4789,6 +4969,24 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-os"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba"
|
||||||
|
dependencies = [
|
||||||
|
"gethostname",
|
||||||
|
"log",
|
||||||
|
"os_info",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serialize-to-javascript",
|
||||||
|
"sys-locale",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-process"
|
name = "tauri-plugin-process"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -5085,6 +5283,16 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
|
|||||||
@@ -33,4 +33,11 @@ tauri-plugin-process = "2"
|
|||||||
blowfish = "0.9"
|
blowfish = "0.9"
|
||||||
md5 = "0.7"
|
md5 = "0.7"
|
||||||
byteorder = "1.5.0"
|
byteorder = "1.5.0"
|
||||||
|
reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
|
||||||
|
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
|
||||||
|
futures-util = "0.3.31"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
walkdir = "2.5.0"
|
||||||
|
unicode-normalization = "0.1.24"
|
||||||
|
tauri-plugin-oauth = "2.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,20 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-path",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"path": "$APPDATA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$APPCONFIG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$APPLOCALDATA"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
@@ -55,11 +69,31 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "http://*.dzcdn.net/**"
|
"url": "http://*.dzcdn.net/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://lrclib.net/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://accounts.spotify.com/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://api.spotify.com/**"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sql:default",
|
"sql:default",
|
||||||
"sql:allow-execute",
|
"sql:allow-execute",
|
||||||
"process:default"
|
"process:default",
|
||||||
|
"os:default",
|
||||||
|
"oauth:allow-start",
|
||||||
|
"oauth:allow-cancel",
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-url",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "https://accounts.spotify.com/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -23,8 +23,7 @@ pub fn generate_blowfish_key(track_id: &str) -> Vec<u8> {
|
|||||||
|
|
||||||
/// Decrypt a single 2048-byte chunk using Blowfish CBC
|
/// Decrypt a single 2048-byte chunk using Blowfish CBC
|
||||||
pub fn decrypt_chunk(chunk: &[u8], blowfish_key: &[u8]) -> Vec<u8> {
|
pub fn decrypt_chunk(chunk: &[u8], blowfish_key: &[u8]) -> Vec<u8> {
|
||||||
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key)
|
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key).expect("Invalid key length");
|
||||||
.expect("Invalid key length");
|
|
||||||
|
|
||||||
let mut result = chunk.to_vec();
|
let mut result = chunk.to_vec();
|
||||||
let iv = [0u8, 1, 2, 3, 4, 5, 6, 7];
|
let iv = [0u8, 1, 2, 3, 4, 5, 6, 7];
|
||||||
@@ -97,6 +96,69 @@ pub fn decrypt_track(data: &[u8], track_id: &str) -> Vec<u8> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Streaming decryption state for processing data chunk-by-chunk
|
||||||
|
pub struct StreamingDecryptor {
|
||||||
|
blowfish_key: Vec<u8>,
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamingDecryptor {
|
||||||
|
const CHUNK_SIZE: usize = 2048;
|
||||||
|
const WINDOW_SIZE: usize = Self::CHUNK_SIZE * 3; // 6144
|
||||||
|
|
||||||
|
pub fn new(track_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
blowfish_key: generate_blowfish_key(track_id),
|
||||||
|
buffer: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process incoming data and return decrypted output
|
||||||
|
/// May return less data than input as it buffers to maintain 6144-byte windows
|
||||||
|
pub fn process(&mut self, data: &[u8]) -> Vec<u8> {
|
||||||
|
self.buffer.extend_from_slice(data);
|
||||||
|
let mut output = Vec::new();
|
||||||
|
|
||||||
|
// Process complete windows
|
||||||
|
while self.buffer.len() >= Self::WINDOW_SIZE {
|
||||||
|
let encrypted_chunk = &self.buffer[0..Self::CHUNK_SIZE];
|
||||||
|
let plain_part = &self.buffer[Self::CHUNK_SIZE..Self::WINDOW_SIZE];
|
||||||
|
|
||||||
|
let decrypted = decrypt_chunk(encrypted_chunk, &self.blowfish_key);
|
||||||
|
output.extend_from_slice(&decrypted);
|
||||||
|
output.extend_from_slice(plain_part);
|
||||||
|
|
||||||
|
self.buffer.drain(0..Self::WINDOW_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalize decryption and return any remaining buffered data
|
||||||
|
pub fn finalize(self) -> Vec<u8> {
|
||||||
|
if self.buffer.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = self.buffer.len();
|
||||||
|
|
||||||
|
if remaining >= Self::CHUNK_SIZE {
|
||||||
|
// Partial window: decrypt first 2048 bytes, keep rest as-is
|
||||||
|
let encrypted_chunk = &self.buffer[0..Self::CHUNK_SIZE];
|
||||||
|
let plain_part = &self.buffer[Self::CHUNK_SIZE..];
|
||||||
|
|
||||||
|
let decrypted = decrypt_chunk(encrypted_chunk, &self.blowfish_key);
|
||||||
|
let mut output = Vec::with_capacity(remaining);
|
||||||
|
output.extend_from_slice(&decrypted);
|
||||||
|
output.extend_from_slice(plain_part);
|
||||||
|
output
|
||||||
|
} else {
|
||||||
|
// Less than 2048 bytes: keep as-is
|
||||||
|
self.buffer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
241
src-tauri/src/device_sync.rs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileInfo {
|
||||||
|
relative_path: String,
|
||||||
|
size: u64,
|
||||||
|
status: String, // "new" | "updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncDiff {
|
||||||
|
files_to_copy: Vec<FileInfo>,
|
||||||
|
stats: SyncStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncStats {
|
||||||
|
new_files: usize,
|
||||||
|
updated_files: usize,
|
||||||
|
unchanged_files: usize,
|
||||||
|
total_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncProgress {
|
||||||
|
current: usize,
|
||||||
|
total: usize,
|
||||||
|
current_file: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileMetadata {
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_skip_file(path: &Path) -> bool {
|
||||||
|
let file_name = path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Skip system files
|
||||||
|
matches!(file_name, ".DS_Store" | "Thumbs.db" | "desktop.ini" | ".nomedia")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_skip_dir(path: &Path) -> bool {
|
||||||
|
let dir_name = path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Skip temp folders
|
||||||
|
dir_name == "_temp"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a path string to NFC form for consistent comparison
|
||||||
|
fn normalize_path(path: &Path) -> String {
|
||||||
|
path.to_string_lossy().nfc().collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index device and compare with library
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn index_and_compare(
|
||||||
|
library_path: String,
|
||||||
|
device_path: String,
|
||||||
|
overwrite_mode: String, // "skip" | "different" | "always"
|
||||||
|
) -> Result<SyncDiff, String> {
|
||||||
|
let library_path = PathBuf::from(library_path);
|
||||||
|
let device_path = PathBuf::from(device_path);
|
||||||
|
|
||||||
|
// Validate paths exist
|
||||||
|
if !library_path.exists() {
|
||||||
|
return Err(format!("Library path does not exist: {}", library_path.display()));
|
||||||
|
}
|
||||||
|
if !device_path.exists() {
|
||||||
|
return Err(format!("Device path does not exist: {}", device_path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Index device - build HashMap of existing files with normalized paths
|
||||||
|
let mut device_files: HashMap<String, FileMetadata> = HashMap::new();
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&device_path)
|
||||||
|
.follow_links(false)
|
||||||
|
.into_iter()
|
||||||
|
.filter_entry(|e| !should_skip_dir(e.path()))
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|e| format!("Error reading device: {}", e))?;
|
||||||
|
|
||||||
|
if entry.file_type().is_file() && !should_skip_file(entry.path()) {
|
||||||
|
let relative_path = entry.path()
|
||||||
|
.strip_prefix(&device_path)
|
||||||
|
.map_err(|e| format!("Path error: {}", e))?
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
|
if let Ok(metadata) = entry.metadata() {
|
||||||
|
let normalized = normalize_path(&relative_path);
|
||||||
|
device_files.insert(normalized, FileMetadata {
|
||||||
|
size: metadata.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Walk library and compare
|
||||||
|
let mut files_to_copy = Vec::new();
|
||||||
|
let mut new_count = 0;
|
||||||
|
let mut updated_count = 0;
|
||||||
|
let mut unchanged_count = 0;
|
||||||
|
let mut total_size = 0u64;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(&library_path)
|
||||||
|
.follow_links(false)
|
||||||
|
.into_iter()
|
||||||
|
.filter_entry(|e| !should_skip_dir(e.path()))
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|e| format!("Error reading library: {}", e))?;
|
||||||
|
|
||||||
|
if entry.file_type().is_file() && !should_skip_file(entry.path()) {
|
||||||
|
let relative_path = entry.path()
|
||||||
|
.strip_prefix(&library_path)
|
||||||
|
.map_err(|e| format!("Path error: {}", e))?
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
|
let metadata = entry.metadata()
|
||||||
|
.map_err(|e| format!("Cannot read file metadata: {}", e))?;
|
||||||
|
|
||||||
|
let file_size = metadata.len();
|
||||||
|
let normalized_path = normalize_path(&relative_path);
|
||||||
|
|
||||||
|
// Check if file exists on device (using normalized path)
|
||||||
|
if let Some(device_meta) = device_files.get(&normalized_path) {
|
||||||
|
// File exists on device
|
||||||
|
let size_different = device_meta.size != file_size;
|
||||||
|
|
||||||
|
let should_copy = match overwrite_mode.as_str() {
|
||||||
|
"skip" => false, // Never overwrite
|
||||||
|
"different" => size_different, // Only if different size
|
||||||
|
"always" => true, // Always overwrite
|
||||||
|
_ => size_different, // Default to "different"
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_copy {
|
||||||
|
files_to_copy.push(FileInfo {
|
||||||
|
relative_path: relative_path.to_string_lossy().to_string(),
|
||||||
|
size: file_size,
|
||||||
|
status: "updated".to_string(),
|
||||||
|
});
|
||||||
|
updated_count += 1;
|
||||||
|
total_size += file_size;
|
||||||
|
} else {
|
||||||
|
unchanged_count += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File doesn't exist on device - new file
|
||||||
|
files_to_copy.push(FileInfo {
|
||||||
|
relative_path: relative_path.to_string_lossy().to_string(),
|
||||||
|
size: file_size,
|
||||||
|
status: "new".to_string(),
|
||||||
|
});
|
||||||
|
new_count += 1;
|
||||||
|
total_size += file_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SyncDiff {
|
||||||
|
files_to_copy,
|
||||||
|
stats: SyncStats {
|
||||||
|
new_files: new_count,
|
||||||
|
updated_files: updated_count,
|
||||||
|
unchanged_files: unchanged_count,
|
||||||
|
total_size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync files to device
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_to_device(
|
||||||
|
app: AppHandle,
|
||||||
|
library_path: String,
|
||||||
|
device_path: String,
|
||||||
|
files_to_copy: Vec<FileInfo>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let library_path = PathBuf::from(library_path);
|
||||||
|
let device_path = PathBuf::from(device_path);
|
||||||
|
|
||||||
|
let total = files_to_copy.len();
|
||||||
|
|
||||||
|
for (index, file_info) in files_to_copy.iter().enumerate() {
|
||||||
|
let source = library_path.join(&file_info.relative_path);
|
||||||
|
let dest = device_path.join(&file_info.relative_path);
|
||||||
|
|
||||||
|
// Emit progress
|
||||||
|
app.emit("sync-progress", SyncProgress {
|
||||||
|
current: index + 1,
|
||||||
|
total,
|
||||||
|
current_file: file_info.relative_path.clone(),
|
||||||
|
status: format!("Copying {} ({} of {})", file_info.relative_path, index + 1, total),
|
||||||
|
}).map_err(|e| format!("Failed to emit progress: {}", e))?;
|
||||||
|
|
||||||
|
// Create parent directory if needed
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file
|
||||||
|
fs::copy(&source, &dest)
|
||||||
|
.map_err(|e| {
|
||||||
|
// Check for common errors
|
||||||
|
match e.kind() {
|
||||||
|
std::io::ErrorKind::PermissionDenied => {
|
||||||
|
format!("Permission denied: {}", dest.display())
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::NotFound => {
|
||||||
|
format!("Device disconnected or path not found: {}", dest.display())
|
||||||
|
}
|
||||||
|
_ => format!("Failed to copy {}: {}", file_info.relative_path, e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit completion
|
||||||
|
app.emit("sync-progress", SyncProgress {
|
||||||
|
current: total,
|
||||||
|
total,
|
||||||
|
current_file: String::new(),
|
||||||
|
status: format!("Sync complete! Copied {} files", total),
|
||||||
|
}).map_err(|e| format!("Failed to emit completion: {}", e))?;
|
||||||
|
|
||||||
|
Ok(format!("Successfully synced {} files to device", total))
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
|
|
||||||
mod tagger;
|
|
||||||
mod metadata;
|
|
||||||
mod deezer_crypto;
|
mod deezer_crypto;
|
||||||
|
mod device_sync;
|
||||||
|
mod metadata;
|
||||||
|
mod tagger;
|
||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -36,7 +37,7 @@ fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String>
|
|||||||
metadata::read_audio_metadata(&path)
|
metadata::read_audio_metadata(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt Deezer track data
|
/// Decrypt Deezer track data (legacy - kept for backwards compatibility)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> {
|
async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> {
|
||||||
// Run decryption on a background thread to avoid blocking the UI
|
// Run decryption on a background thread to avoid blocking the UI
|
||||||
@@ -49,6 +50,135 @@ async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download and decrypt a Deezer track, streaming directly to disk
|
||||||
|
#[tauri::command]
|
||||||
|
async fn download_and_decrypt_track(
|
||||||
|
url: String,
|
||||||
|
track_id: String,
|
||||||
|
output_path: String,
|
||||||
|
is_encrypted: bool,
|
||||||
|
window: tauri::Window,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use deezer_crypto::StreamingDecryptor;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
// Build HTTP client
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
// Start download
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Download failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("HTTP error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_size = response.content_length().unwrap_or(0) as f64;
|
||||||
|
|
||||||
|
// Open output file
|
||||||
|
let mut file = File::create(&output_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||||
|
|
||||||
|
let mut downloaded_bytes = 0u64;
|
||||||
|
let mut last_reported_percentage = 0u8;
|
||||||
|
|
||||||
|
// Stream download with optional decryption
|
||||||
|
if is_encrypted {
|
||||||
|
let mut decryptor = StreamingDecryptor::new(&track_id);
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
while let Some(chunk_result) = stream.next().await {
|
||||||
|
let chunk = chunk_result.map_err(|e| format!("Download stream error: {}", e))?;
|
||||||
|
downloaded_bytes += chunk.len() as u64;
|
||||||
|
|
||||||
|
// Decrypt chunk and write to file
|
||||||
|
let decrypted = decryptor.process(&chunk);
|
||||||
|
if !decrypted.is_empty() {
|
||||||
|
file.write_all(&decrypted)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write to file: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit progress every 5%
|
||||||
|
if total_size > 0.0 {
|
||||||
|
let percentage = ((downloaded_bytes as f64 / total_size) * 100.0) as u8;
|
||||||
|
let rounded_percentage = (percentage / 5) * 5;
|
||||||
|
|
||||||
|
if rounded_percentage > last_reported_percentage || percentage == 100 {
|
||||||
|
last_reported_percentage = rounded_percentage;
|
||||||
|
let _ = window.emit(
|
||||||
|
"download-progress",
|
||||||
|
serde_json::json!({
|
||||||
|
"downloaded": downloaded_bytes,
|
||||||
|
"total": total_size as u64,
|
||||||
|
"percentage": percentage
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write any remaining buffered data
|
||||||
|
let final_data = decryptor.finalize();
|
||||||
|
if !final_data.is_empty() {
|
||||||
|
file.write_all(&final_data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write final data: {}", e))?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No encryption - just stream directly
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
while let Some(chunk_result) = stream.next().await {
|
||||||
|
let chunk = chunk_result.map_err(|e| format!("Download stream error: {}", e))?;
|
||||||
|
downloaded_bytes += chunk.len() as u64;
|
||||||
|
|
||||||
|
file.write_all(&chunk)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write to file: {}", e))?;
|
||||||
|
|
||||||
|
// Emit progress every 5%
|
||||||
|
if total_size > 0.0 {
|
||||||
|
let percentage = ((downloaded_bytes as f64 / total_size) * 100.0) as u8;
|
||||||
|
let rounded_percentage = (percentage / 5) * 5;
|
||||||
|
|
||||||
|
if rounded_percentage > last_reported_percentage || percentage == 100 {
|
||||||
|
last_reported_percentage = rounded_percentage;
|
||||||
|
let _ = window.emit(
|
||||||
|
"download-progress",
|
||||||
|
serde_json::json!({
|
||||||
|
"downloaded": downloaded_bytes,
|
||||||
|
"total": total_size as u64,
|
||||||
|
"percentage": percentage
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all data is flushed
|
||||||
|
file.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to flush file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let library_migrations = vec![Migration {
|
let library_migrations = vec![Migration {
|
||||||
@@ -80,10 +210,25 @@ pub fn run() {
|
|||||||
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE
|
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tracks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
album TEXT NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
has_lyrics INTEGER DEFAULT 0,
|
||||||
|
last_scanned INTEGER,
|
||||||
|
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name);
|
CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id);
|
CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
|
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
|
||||||
CREATE INDEX IF NOT EXISTS idx_albums_artist_title ON albums(artist_name, title);
|
CREATE INDEX IF NOT EXISTS idx_albums_artist_title ON albums(artist_name, title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tracks_has_lyrics ON tracks(has_lyrics);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tracks_path ON tracks(path);
|
||||||
",
|
",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
}];
|
}];
|
||||||
@@ -154,12 +299,83 @@ pub fn run() {
|
|||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
let spotify_migrations = vec![Migration {
|
||||||
|
version: 1,
|
||||||
|
description: "create_spotify_cache_tables",
|
||||||
|
sql: "
|
||||||
|
CREATE TABLE IF NOT EXISTS spotify_playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
track_count INTEGER DEFAULT 0,
|
||||||
|
owner_name TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spotify_albums (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
track_count INTEGER DEFAULT 0,
|
||||||
|
release_date TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spotify_artists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
followers INTEGER DEFAULT 0,
|
||||||
|
image_url TEXT,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spotify_tracks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
album_name TEXT,
|
||||||
|
duration_ms INTEGER DEFAULT 0,
|
||||||
|
isrc TEXT,
|
||||||
|
album_image_url TEXT,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spotify_playlist_tracks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
album_name TEXT,
|
||||||
|
duration_ms INTEGER DEFAULT 0,
|
||||||
|
track_number INTEGER,
|
||||||
|
isrc TEXT,
|
||||||
|
cached_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(playlist_id, track_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spotify_playlists_name ON spotify_playlists(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spotify_albums_artist ON spotify_albums(artist_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spotify_artists_name ON spotify_artists(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spotify_tracks_name ON spotify_tracks(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spotify_tracks_isrc ON spotify_tracks(isrc);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_playlist ON spotify_playlist_tracks(playlist_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_track ON spotify_playlist_tracks(track_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_isrc ON spotify_playlist_tracks(isrc);
|
||||||
|
",
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
}];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_oauth::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_sql::Builder::new()
|
tauri_plugin_sql::Builder::new()
|
||||||
.add_migrations("sqlite:library.db", library_migrations)
|
.add_migrations("sqlite:library.db", library_migrations)
|
||||||
.add_migrations("sqlite:deezer.db", deezer_migrations)
|
.add_migrations("sqlite:deezer.db", deezer_migrations)
|
||||||
|
.add_migrations("sqlite:spotify.db", spotify_migrations)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
@@ -171,7 +387,10 @@ pub fn run() {
|
|||||||
greet,
|
greet,
|
||||||
tag_audio_file,
|
tag_audio_file,
|
||||||
read_audio_metadata,
|
read_audio_metadata,
|
||||||
decrypt_deezer_track
|
decrypt_deezer_track,
|
||||||
|
download_and_decrypt_track,
|
||||||
|
device_sync::index_and_compare,
|
||||||
|
device_sync::sync_to_device
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use metaflac::Tag as FlacTag;
|
|
||||||
use id3::{Tag as ID3Tag, TagLike};
|
use id3::{Tag as ID3Tag, TagLike};
|
||||||
|
use metaflac::Tag as FlacTag;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ pub fn read_audio_metadata(path: &str) -> Result<AudioMetadata, String> {
|
|||||||
|
|
||||||
/// Read metadata from MP3 file
|
/// Read metadata from MP3 file
|
||||||
fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
|
fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||||
let tag = ID3Tag::read_from_path(path)
|
let tag =
|
||||||
.map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
|
ID3Tag::read_from_path(path).map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
|
||||||
|
|
||||||
Ok(AudioMetadata {
|
Ok(AudioMetadata {
|
||||||
title: tag.title().map(|s| s.to_string()),
|
title: tag.title().map(|s| s.to_string()),
|
||||||
@@ -55,8 +55,8 @@ fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
|
|||||||
|
|
||||||
/// Read metadata from FLAC file
|
/// Read metadata from FLAC file
|
||||||
fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
|
fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||||
let tag = FlacTag::read_from_path(path)
|
let tag =
|
||||||
.map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
|
FlacTag::read_from_path(path).map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
|
||||||
|
|
||||||
// Helper to get first value from vorbis comment
|
// Helper to get first value from vorbis comment
|
||||||
let get_first = |key: &str| -> Option<String> {
|
let get_first = |key: &str| -> Option<String> {
|
||||||
@@ -66,8 +66,7 @@ fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parse track number
|
// Parse track number
|
||||||
let track_number = get_first("TRACKNUMBER")
|
let track_number = get_first("TRACKNUMBER").and_then(|s| s.parse::<u32>().ok());
|
||||||
.and_then(|s| s.parse::<u32>().ok());
|
|
||||||
|
|
||||||
// Get duration from streaminfo block (in samples)
|
// Get duration from streaminfo block (in samples)
|
||||||
let duration = tag.get_streaminfo().map(|info| {
|
let duration = tag.get_streaminfo().map(|info| {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'",
|
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; media-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'",
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": ["**"]
|
"scope": ["**"]
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { beforeNavigate } from '$app/navigation';
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
|
||||||
|
let { onToggleNowPlaying }: { onToggleNowPlaying?: () => void } = $props();
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
back: '/icons/leftarrow.png',
|
back: '/icons/leftarrow.png',
|
||||||
forward: '/icons/rightarrow.png',
|
forward: '/icons/rightarrow.png',
|
||||||
play: '/icons/speaker.png',
|
play: '/icons/speaker.png',
|
||||||
search: '/icons/internet.png',
|
search: '/icons/internet.png',
|
||||||
globe: '/icons/github-white.svg',
|
|
||||||
computer: '/icons/computer.png',
|
computer: '/icons/computer.png',
|
||||||
|
device: '/icons/ipod.svg',
|
||||||
};
|
};
|
||||||
|
|
||||||
let history: string[] = $state([]);
|
let history: string[] = $state([]);
|
||||||
@@ -36,6 +38,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleNowPlayingClick() {
|
||||||
|
if (onToggleNowPlaying) {
|
||||||
|
onToggleNowPlaying();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (history.length === 0 && typeof window !== 'undefined') {
|
if (history.length === 0 && typeof window !== 'undefined') {
|
||||||
history = [window.location.pathname];
|
history = [window.location.pathname];
|
||||||
@@ -64,7 +72,7 @@
|
|||||||
|
|
||||||
<div class="toolbar-separator"></div>
|
<div class="toolbar-separator"></div>
|
||||||
|
|
||||||
<button class="toolbar-button" disabled title="Now Playing">
|
<button class="toolbar-button" onclick={handleNowPlayingClick} title="Now Playing">
|
||||||
<img src={icons.play} alt="Play" />
|
<img src={icons.play} alt="Play" />
|
||||||
<span>Now Playing</span>
|
<span>Now Playing</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -73,18 +81,17 @@
|
|||||||
<img src={icons.search} alt="Search" />
|
<img src={icons.search} alt="Search" />
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/sync" class="toolbar-button" title="Device Sync">
|
||||||
|
<img src={icons.device} alt="Device Sync" />
|
||||||
|
<span>Sync</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="/settings" class="toolbar-button" title="Settings">
|
<a href="/settings" class="toolbar-button" title="Settings">
|
||||||
<img src={icons.computer} alt="Settings" />
|
<img src={icons.computer} alt="Settings" />
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="toolbar-separator"></div>
|
|
||||||
|
|
||||||
<button class="toolbar-button" disabled title="GitHub">
|
|
||||||
<img src={icons.globe} alt="Globe" />
|
|
||||||
<span>GitHub</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Track } from '$lib/types/track';
|
import type { Track } from '$lib/types/track';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
|
import { playback } from '$lib/stores/playback';
|
||||||
|
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
|
||||||
|
import PageDecoration from '$lib/components/PageDecoration.svelte';
|
||||||
|
import { fetchAndSaveLyrics } from '$lib/services/lrclib';
|
||||||
|
import { setSuccess, setWarning, setError } from '$lib/stores/status';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,6 +16,8 @@
|
|||||||
selectedTrackIndex?: number | null;
|
selectedTrackIndex?: number | null;
|
||||||
onTrackClick?: (index: number) => void;
|
onTrackClick?: (index: number) => void;
|
||||||
showAlbumColumn?: boolean;
|
showAlbumColumn?: boolean;
|
||||||
|
useSequentialNumbers?: boolean;
|
||||||
|
decorationLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,9 +28,13 @@
|
|||||||
tracks,
|
tracks,
|
||||||
selectedTrackIndex = null,
|
selectedTrackIndex = null,
|
||||||
onTrackClick,
|
onTrackClick,
|
||||||
showAlbumColumn = false
|
showAlbumColumn = false,
|
||||||
|
useSequentialNumbers = false,
|
||||||
|
decorationLabel = 'LOCAL PLAYLIST'
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
|
||||||
|
|
||||||
function getThumbnailUrl(coverPath?: string): string {
|
function getThumbnailUrl(coverPath?: string): string {
|
||||||
if (!coverPath) {
|
if (!coverPath) {
|
||||||
return '';
|
return '';
|
||||||
@@ -36,8 +47,71 @@
|
|||||||
onTrackClick(index);
|
onTrackClick(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTrackDoubleClick(index: number) {
|
||||||
|
// Play track immediately (replace queue)
|
||||||
|
playback.playQueue(tracks, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContextMenu(e: MouseEvent, index: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
trackIndex: index
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFetchLyrics(trackIndex: number) {
|
||||||
|
const track = tracks[trackIndex];
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchAndSaveLyrics(track.path, {
|
||||||
|
title: track.metadata.title || 'Unknown',
|
||||||
|
artist: track.metadata.artist || 'Unknown Artist',
|
||||||
|
album: track.metadata.album || 'Unknown Album',
|
||||||
|
duration: track.metadata.duration || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.instrumental) {
|
||||||
|
setWarning(`${track.metadata.title || track.filename} is instrumental`);
|
||||||
|
} else if (result.hasLyrics) {
|
||||||
|
setSuccess(`Lyrics fetched for ${track.metadata.title || track.filename}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setWarning(`No lyrics found for ${track.metadata.title || track.filename}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(`Failed to fetch lyrics for ${track.metadata.title || track.filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextMenuItems(trackIndex: number): MenuItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Play Now',
|
||||||
|
action: () => playback.playQueue(tracks, trackIndex)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Add to Queue',
|
||||||
|
action: () => playback.addToQueue([tracks[trackIndex]])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Play Next',
|
||||||
|
action: () => playback.playNext([tracks[trackIndex]])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fetch Lyrics via LRCLIB',
|
||||||
|
action: () => handleFetchLyrics(trackIndex)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<PageDecoration label={decorationLabel} />
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="collection-header">
|
<div class="collection-header">
|
||||||
{#if coverArtPath}
|
{#if coverArtPath}
|
||||||
@@ -98,9 +172,11 @@
|
|||||||
<tr
|
<tr
|
||||||
class:highlighted={selectedTrackIndex === i}
|
class:highlighted={selectedTrackIndex === i}
|
||||||
onclick={() => handleTrackClick(i)}
|
onclick={() => handleTrackClick(i)}
|
||||||
|
ondblclick={() => handleTrackDoubleClick(i)}
|
||||||
|
oncontextmenu={(e) => handleContextMenu(e, i)}
|
||||||
>
|
>
|
||||||
<td class="track-number">
|
<td class="track-number">
|
||||||
{track.metadata.trackNumber ?? i + 1}
|
{useSequentialNumbers ? i + 1 : (track.metadata.trackNumber ?? i + 1)}
|
||||||
</td>
|
</td>
|
||||||
<td>{track.metadata.title ?? '—'}</td>
|
<td>{track.metadata.title ?? '—'}</td>
|
||||||
{#if showAlbumColumn}
|
{#if showAlbumColumn}
|
||||||
@@ -124,6 +200,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if contextMenu}
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
items={getContextMenuItems(contextMenu.trackIndex)}
|
||||||
|
onClose={() => contextMenu = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.collection-header {
|
.collection-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -206,6 +291,13 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|||||||
117
src/lib/components/ContextMenu.svelte
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
action: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: MenuItem[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { x, y, items, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let menuElement = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Close on click outside
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (menuElement && !menuElement.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on escape
|
||||||
|
function handleEscape(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleItemClick(item: MenuItem) {
|
||||||
|
if (!item.disabled) {
|
||||||
|
item.action();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent, item: MenuItem) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleItemClick(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={menuElement}
|
||||||
|
class="context-menu"
|
||||||
|
style="left: {x}px; top: {y}px;"
|
||||||
|
>
|
||||||
|
<ul role="menu">
|
||||||
|
{#each items as item}
|
||||||
|
<li
|
||||||
|
role="menuitem"
|
||||||
|
class:disabled={item.disabled}
|
||||||
|
onclick={() => handleItemClick(item)}
|
||||||
|
onkeydown={(e) => handleKeyDown(e, item)}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
background: light-dark(#c0c0c0, #121212);
|
||||||
|
border: 1px solid light-dark(#0a0a0a, #000);
|
||||||
|
box-shadow: inset -1px -1px light-dark(#0a0a0a, #000),
|
||||||
|
inset 1px 1px light-dark(#fff, #525252),
|
||||||
|
inset -2px -2px light-dark(grey, #232323),
|
||||||
|
inset 2px 2px light-dark(#dfdfdf, #363636);
|
||||||
|
padding: 2px;
|
||||||
|
font-family: "Pixelated MS Sans Serif", Arial;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 4px 24px 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: light-dark(#222, #ddd);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover:not(.disabled) {
|
||||||
|
background: light-dark(#000080, #1084d0);
|
||||||
|
color: light-dark(#fff, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Track } from '$lib/types/track';
|
import type { Track } from '$lib/types/track';
|
||||||
|
import PageDecoration from '$lib/components/PageDecoration.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -64,6 +65,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<PageDecoration label="DEEZER PLAYLIST" />
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="collection-header">
|
<div class="collection-header">
|
||||||
{#if coverImageUrl}
|
{#if coverImageUrl}
|
||||||
@@ -277,7 +280,7 @@
|
|||||||
|
|
||||||
.track-number {
|
.track-number {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.6;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
|
|||||||
165
src/lib/components/LyricsDisplay.svelte
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readTextFile, exists } from '@tauri-apps/plugin-fs';
|
||||||
|
|
||||||
|
interface LyricLine {
|
||||||
|
timestamp: number; // in seconds
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { lrcPath, currentTime }: { lrcPath: string | null; currentTime: number } = $props();
|
||||||
|
|
||||||
|
let lyrics = $state<LyricLine[]>([]);
|
||||||
|
let isSynced = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
// Load LRC file when path changes
|
||||||
|
$effect(() => {
|
||||||
|
if (lrcPath) {
|
||||||
|
loadLyrics(lrcPath);
|
||||||
|
} else {
|
||||||
|
lyrics = [];
|
||||||
|
isSynced = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let scrollContainer = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Find current active lyric line for synced lyrics
|
||||||
|
const activeLyricIndex = $derived(() => {
|
||||||
|
if (!isSynced || lyrics.length === 0) return -1;
|
||||||
|
|
||||||
|
// Find the last lyric whose timestamp has passed
|
||||||
|
for (let i = lyrics.length - 1; i >= 0; i--) {
|
||||||
|
if (currentTime >= lyrics[i].timestamp) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll to active line for synced lyrics
|
||||||
|
$effect(() => {
|
||||||
|
const activeIndex = activeLyricIndex();
|
||||||
|
if (isSynced && activeIndex >= 0 && scrollContainer) {
|
||||||
|
const activeElement = scrollContainer.children[activeIndex] as HTMLElement;
|
||||||
|
if (activeElement) {
|
||||||
|
activeElement.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLyrics(path: string) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const fileExists = await exists(path);
|
||||||
|
if (!fileExists) {
|
||||||
|
lyrics = [];
|
||||||
|
isSynced = false;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await readTextFile(path);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const parsedLyrics: LyricLine[] = [];
|
||||||
|
let hasSyncedLines = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Try to match synced format: [00:00.00]text
|
||||||
|
const syncedMatch = line.match(/\[(\d{2}):(\d{2})\.(\d{2})\](.+)/);
|
||||||
|
if (syncedMatch) {
|
||||||
|
const minutes = parseInt(syncedMatch[1], 10);
|
||||||
|
const seconds = parseInt(syncedMatch[2], 10);
|
||||||
|
const centiseconds = parseInt(syncedMatch[3], 10);
|
||||||
|
const text = syncedMatch[4].trim();
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
const timestamp = minutes * 60 + seconds + centiseconds / 100;
|
||||||
|
parsedLyrics.push({ timestamp, text });
|
||||||
|
hasSyncedLines = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain text line (unsynced)
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('[')) {
|
||||||
|
parsedLyrics.push({ timestamp: 0, text: trimmed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics = parsedLyrics;
|
||||||
|
isSynced = hasSyncedLines;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Lyrics] Error loading LRC file:', error);
|
||||||
|
lyrics = [];
|
||||||
|
isSynced = false;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if lyrics.length > 0}
|
||||||
|
<div class="lyrics-display">
|
||||||
|
<div class="lyrics-scroll" bind:this={scrollContainer}>
|
||||||
|
{#each lyrics as line, i}
|
||||||
|
<p class="lyric-line" class:active={isSynced && activeLyricIndex() === i}>
|
||||||
|
{line.text}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if loading}
|
||||||
|
<div class="lyrics-display">
|
||||||
|
<p class="no-lyrics">Loading lyrics...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="lyrics-display">
|
||||||
|
<p class="no-lyrics">No lyrics available</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lyrics-display {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
font-family: "Pixelated MS Sans Serif", Arial;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.6;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-line {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-line.active {
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
color: light-dark(#000080, #1084d0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-lyrics {
|
||||||
|
margin: auto;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
326
src/lib/components/NowPlayingPanel.svelte
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { playback } from '$lib/stores/playback';
|
||||||
|
import { audioPlayer } from '$lib/services/audioPlayer';
|
||||||
|
import LyricsDisplay from '$lib/components/LyricsDisplay.svelte';
|
||||||
|
import TriangleVolumeSlider from '$lib/components/TriangleVolumeSlider.svelte';
|
||||||
|
|
||||||
|
// Local volume state that syncs with the store
|
||||||
|
let volume = $state($playback.volume);
|
||||||
|
|
||||||
|
// Sync local volume with store volume
|
||||||
|
$effect(() => {
|
||||||
|
volume = $playback.volume;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handlePlayPause() {
|
||||||
|
playback.togglePlayPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrevious() {
|
||||||
|
playback.previous();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
playback.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProgressChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const time = parseFloat(target.value);
|
||||||
|
audioPlayer.seek(time);
|
||||||
|
playback.setCurrentTime(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVolumeChange(newVolume: number) {
|
||||||
|
playback.setVolume(newVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
if (!isFinite(seconds)) return '0:00';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTrack = $derived($playback.currentTrack !== null);
|
||||||
|
const progressPercent = $derived(
|
||||||
|
$playback.duration > 0 ? ($playback.currentTime / $playback.duration) * 100 : 0
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="now-playing">
|
||||||
|
<div class="player-main">
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-title-row">
|
||||||
|
<img src="/icons/play.svg" alt="" class="track-icon" />
|
||||||
|
<div class="track-text-content">
|
||||||
|
<div class="track-title">
|
||||||
|
{#if hasTrack && $playback.currentTrack}
|
||||||
|
{$playback.currentTrack.metadata.title || $playback.currentTrack.filename}
|
||||||
|
{:else}
|
||||||
|
No track playing
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="track-artist">
|
||||||
|
{#if hasTrack && $playback.currentTrack}
|
||||||
|
{$playback.currentTrack.metadata.artist || 'Unknown Artist'}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-controls-container">
|
||||||
|
<div class="progress-section">
|
||||||
|
<span class="time-display">{formatTime($playback.currentTime)}</span>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-indicator">
|
||||||
|
<span class="progress-indicator-bar" style="width: {progressPercent}%;"></span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={$playback.duration || 0}
|
||||||
|
step="0.1"
|
||||||
|
value={$playback.currentTime}
|
||||||
|
oninput={handleProgressChange}
|
||||||
|
disabled={!hasTrack}
|
||||||
|
class="progress-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="time-display">{formatTime($playback.duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls-row">
|
||||||
|
<div class="controls">
|
||||||
|
<button
|
||||||
|
class="control-button"
|
||||||
|
onclick={handlePrevious}
|
||||||
|
disabled={!hasTrack}
|
||||||
|
title="Previous"
|
||||||
|
>
|
||||||
|
<img src="/icons/player-skip-back.svg" alt="Previous" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="control-button play-pause"
|
||||||
|
onclick={handlePlayPause}
|
||||||
|
disabled={!hasTrack}
|
||||||
|
title={$playback.isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{#if $playback.isPlaying}
|
||||||
|
<img src="/icons/player-pause.svg" alt="Pause" />
|
||||||
|
{:else}
|
||||||
|
<img src="/icons/player-play.svg" alt="Play" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="control-button"
|
||||||
|
onclick={() => playback.stop()}
|
||||||
|
disabled={!hasTrack}
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
<img src="/icons/player-stop.svg" alt="Stop" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="control-button"
|
||||||
|
onclick={handleNext}
|
||||||
|
disabled={!hasTrack}
|
||||||
|
title="Next"
|
||||||
|
>
|
||||||
|
<img src="/icons/player-skip-forward.svg" alt="Next" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="volume-section">
|
||||||
|
<TriangleVolumeSlider bind:value={volume} onchange={handleVolumeChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LyricsDisplay lrcPath={$playback.lrcPath} currentTime={$playback.currentTime} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.now-playing {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
height: 100%;
|
||||||
|
font-family: "Pixelated MS Sans Serif", Arial;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
padding: 6px 20px;
|
||||||
|
min-width: auto;
|
||||||
|
min-height: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0 -1px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button img {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
filter: light-dark(none, invert(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause {
|
||||||
|
padding: 6px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause img {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
filter: light-dark(none, invert(1));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-text-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-indicator {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-slider {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-slider:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-section {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: hide lyrics panel when not enough space */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.now-playing :global(.lyrics-display) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls-container {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: simplify track info on very small widths */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.track-artist {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
src/lib/components/PageDecoration.svelte
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label }: Props = $props();
|
||||||
|
|
||||||
|
// Calculate width based on text length (approximate monospace character width)
|
||||||
|
let labelWidth = $derived(label.length * 7 + 26); // ~7px per char + padding
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page-decoration">
|
||||||
|
<div class="decoration-left" style="width: {labelWidth}px;"></div>
|
||||||
|
<!-- vector from /static/vectors/title-decoration.svg -->
|
||||||
|
<svg class="decoration-transition" viewBox="0 0 64 32" preserveAspectRatio="none">
|
||||||
|
<path d="M64,0H0v32h21.634c3.056-9.369,6.236-15.502,19.82-17.258,2.105-.272,4.23-.37,6.352-.37h16.193V0Z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="decoration-right"></div>
|
||||||
|
<div class="decoration-label">//{label}//</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-decoration {
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-left {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #373737;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-transition {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
fill: #373737;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-right {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(to bottom, #373737 0%, #373737 9.184px, transparent 9.184px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
324
src/lib/components/SpotifyCollectionView.svelte
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Track } from '$lib/types/track';
|
||||||
|
import PageDecoration from '$lib/components/PageDecoration.svelte';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
metadata?: string;
|
||||||
|
coverImageUrl?: string;
|
||||||
|
tracks: Track[];
|
||||||
|
selectedTrackIndex?: number | null;
|
||||||
|
onTrackClick?: (index: number) => void;
|
||||||
|
onDownloadTrack?: (index: number) => void;
|
||||||
|
onDownloadPlaylist?: () => void;
|
||||||
|
downloadingTrackIds?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
metadata,
|
||||||
|
coverImageUrl,
|
||||||
|
tracks,
|
||||||
|
selectedTrackIndex = null,
|
||||||
|
onTrackClick,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadPlaylist,
|
||||||
|
downloadingTrackIds = new Set()
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
type ViewMode = 'tracks' | 'info';
|
||||||
|
let viewMode = $state<ViewMode>('tracks');
|
||||||
|
|
||||||
|
function handleTrackClick(index: number) {
|
||||||
|
if (onTrackClick) {
|
||||||
|
onTrackClick(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadClick(index: number, event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (onDownloadTrack) {
|
||||||
|
onDownloadTrack(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackDownloading(track: Track): boolean {
|
||||||
|
const trackId = (track as any).spotifyId?.toString();
|
||||||
|
if (!trackId) return false;
|
||||||
|
return downloadingTrackIds.has(trackId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageDecoration label="SPOTIFY PLAYLIST" />
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="collection-header">
|
||||||
|
{#if coverImageUrl}
|
||||||
|
<img
|
||||||
|
src={coverImageUrl}
|
||||||
|
alt="{title} cover"
|
||||||
|
class="collection-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="collection-cover-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="collection-info">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{#if subtitle}
|
||||||
|
<p class="collection-subtitle">{subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
{#if metadata}
|
||||||
|
<p class="collection-metadata">{metadata}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="collection-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
-->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={viewMode === 'tracks'}>
|
||||||
|
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'info'}>
|
||||||
|
<button onclick={() => viewMode = 'info'}>Info</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
{#if viewMode === 'tracks'}
|
||||||
|
<!-- Track Listing -->
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;">#</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
{#if $deezerAuth.loggedIn}
|
||||||
|
<th style="width: 100px;">Actions</th>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tracks as track, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedTrackIndex === i}
|
||||||
|
onclick={() => handleTrackClick(i)}
|
||||||
|
>
|
||||||
|
<td class="track-number">
|
||||||
|
{track.metadata.trackNumber ?? i + 1}
|
||||||
|
</td>
|
||||||
|
<td>{track.metadata.title ?? '—'}</td>
|
||||||
|
<td>{track.metadata.artist ?? '—'}</td>
|
||||||
|
<td>{track.metadata.album ?? '—'}</td>
|
||||||
|
<td class="duration">
|
||||||
|
{#if track.metadata.duration}
|
||||||
|
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{#if $deezerAuth.loggedIn}
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
onclick={(e) => handleDownloadClick(i, e)}
|
||||||
|
disabled={isTrackDownloading(track)}
|
||||||
|
class="download-btn"
|
||||||
|
>
|
||||||
|
{isTrackDownloading(track) ? 'Queued' : 'Download'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'info'}
|
||||||
|
<!-- Playlist Info -->
|
||||||
|
<div class="info-container">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Playlist Information</legend>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Title:</span>
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
{#if subtitle}
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Creator:</span>
|
||||||
|
<span>{subtitle}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Tracks:</span>
|
||||||
|
<span>{tracks.length}</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if $deezerAuth.loggedIn}
|
||||||
|
<fieldset style="margin-top: 16px;">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
<button onclick={onDownloadPlaylist}>
|
||||||
|
Download Playlist
|
||||||
|
</button>
|
||||||
|
<p class="help-text">Download all tracks via Deezer and save as m3u8 playlist</p>
|
||||||
|
</fieldset>
|
||||||
|
{:else}
|
||||||
|
<fieldset style="margin-top: 16px;">
|
||||||
|
<legend>Downloads</legend>
|
||||||
|
<p class="warning-text">Deezer login required to download Spotify tracks</p>
|
||||||
|
<p class="help-text">Sign in to Deezer in Services → Deezer to enable downloads</p>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.collection-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-cover {
|
||||||
|
width: 152px;
|
||||||
|
height: 152px;
|
||||||
|
object-fit: cover;
|
||||||
|
image-rendering: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-cover-placeholder {
|
||||||
|
width: 152px;
|
||||||
|
height: 152px;
|
||||||
|
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-subtitle {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-metadata {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
text-align: center;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
257
src/lib/components/TriangleVolumeSlider.svelte
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
value: number; // 0-1
|
||||||
|
onchange?: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = $bindable(0.75), onchange }: Props = $props();
|
||||||
|
|
||||||
|
let volume = $state(value * 100); // Internal state as 0-100
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let dragOffset = $state(0);
|
||||||
|
let triangleContainer: HTMLDivElement;
|
||||||
|
let volumeThumb: HTMLDivElement;
|
||||||
|
|
||||||
|
const triangleWidth = 60;
|
||||||
|
|
||||||
|
// Sync external value (0-1) with internal volume (0-100)
|
||||||
|
$effect(() => {
|
||||||
|
volume = value * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateVolume(percentage: number) {
|
||||||
|
volume = Math.max(0, Math.min(100, percentage));
|
||||||
|
const newValue = volume / 100;
|
||||||
|
value = newValue;
|
||||||
|
if (onchange) {
|
||||||
|
onchange(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVolumeFromPosition(clientX: number, useOffset: boolean = false): number {
|
||||||
|
const rect = triangleContainer.getBoundingClientRect();
|
||||||
|
let x = clientX - rect.left;
|
||||||
|
|
||||||
|
if (useOffset) {
|
||||||
|
x = x - dragOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = (x / rect.width) * 100;
|
||||||
|
return percentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleThumbMouseDown(e: MouseEvent) {
|
||||||
|
isDragging = true;
|
||||||
|
|
||||||
|
const rect = triangleContainer.getBoundingClientRect();
|
||||||
|
const clickX = e.clientX - rect.left;
|
||||||
|
const currentThumbPosition = (volume / 100) * rect.width;
|
||||||
|
|
||||||
|
dragOffset = clickX - currentThumbPosition;
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContainerMouseDown(e: MouseEvent) {
|
||||||
|
if (e.target !== volumeThumb) {
|
||||||
|
dragOffset = 0;
|
||||||
|
const newVolume = getVolumeFromPosition(e.clientX);
|
||||||
|
updateVolume(newVolume);
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
if (isDragging) {
|
||||||
|
const newVolume = getVolumeFromPosition(e.clientX, true);
|
||||||
|
updateVolume(newVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
isDragging = false;
|
||||||
|
dragOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleThumbTouchStart(e: TouchEvent) {
|
||||||
|
isDragging = true;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const rect = triangleContainer.getBoundingClientRect();
|
||||||
|
const currentThumbPosition = (volume / 100) * rect.width;
|
||||||
|
dragOffset = (touch.clientX - rect.left) - currentThumbPosition;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContainerTouchStart(e: TouchEvent) {
|
||||||
|
if (e.target !== volumeThumb) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
dragOffset = 0;
|
||||||
|
const newVolume = getVolumeFromPosition(touch.clientX, false);
|
||||||
|
updateVolume(newVolume);
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
if (isDragging) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const newVolume = getVolumeFromPosition(touch.clientX, true);
|
||||||
|
updateVolume(newVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd() {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDecrease() {
|
||||||
|
updateVolume(volume - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIncrease() {
|
||||||
|
updateVolume(volume + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = $derived((volume / 100) * triangleWidth);
|
||||||
|
const rightEdge = $derived(volume);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onmousemove={handleMouseMove} onmouseup={handleMouseUp} ontouchmove={handleTouchMove} ontouchend={handleTouchEnd} />
|
||||||
|
|
||||||
|
<div class="volume-container">
|
||||||
|
<button class="volume-icons" onclick={handleDecrease} aria-label="Decrease volume">−</button>
|
||||||
|
|
||||||
|
<div class="triangle-wrapper">
|
||||||
|
<div
|
||||||
|
class="triangle-container"
|
||||||
|
bind:this={triangleContainer}
|
||||||
|
onmousedown={handleContainerMouseDown}
|
||||||
|
ontouchstart={handleContainerTouchStart}
|
||||||
|
role="slider"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow={Math.round(value * 100)}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="triangle-bg"></div>
|
||||||
|
<div
|
||||||
|
class="triangle-fill"
|
||||||
|
style="clip-path: inset(0 {100 - rightEdge}% 0 0);"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="volume-thumb"
|
||||||
|
style="left: {position}px;"
|
||||||
|
bind:this={volumeThumb}
|
||||||
|
onmousedown={handleThumbMouseDown}
|
||||||
|
ontouchstart={handleThumbTouchStart}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Volume slider thumb"
|
||||||
|
>
|
||||||
|
<div class="volume-thumb-inner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="volume-icons" onclick={handleIncrease} aria-label="Increase volume">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.volume-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-icons {
|
||||||
|
font-size: 12px;
|
||||||
|
color: light-dark(#000, #fff);
|
||||||
|
user-select: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
min-width: auto;
|
||||||
|
min-height: auto;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 60px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle-container {
|
||||||
|
position: relative;
|
||||||
|
width: 60px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle-bg {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 60px solid transparent;
|
||||||
|
border-right: 0 solid transparent;
|
||||||
|
border-bottom: 16px solid light-dark(#808080, #525252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle-fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 60px solid transparent;
|
||||||
|
border-right: 0 solid transparent;
|
||||||
|
border-bottom: 16px solid light-dark(#000080, #0066cc);
|
||||||
|
transition: clip-path 0.05s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-thumb {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 16px;
|
||||||
|
background: light-dark(#c0c0c0, #2b2b2b);
|
||||||
|
border-top: 1px solid light-dark(#ffffff, #525252);
|
||||||
|
border-left: 1px solid light-dark(#ffffff, #525252);
|
||||||
|
border-right: 1px solid light-dark(#000000, #000);
|
||||||
|
border-bottom: 1px solid light-dark(#000000, #000);
|
||||||
|
box-shadow: inset 1px 1px 0 light-dark(#dfdfdf, #363636),
|
||||||
|
inset -1px -1px 0 light-dark(#808080, #232323);
|
||||||
|
cursor: grab;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-thumb:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
border-top: 1px solid light-dark(#000000, #000);
|
||||||
|
border-left: 1px solid light-dark(#000000, #000);
|
||||||
|
border-right: 1px solid light-dark(#ffffff, #525252);
|
||||||
|
border-bottom: 1px solid light-dark(#ffffff, #525252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-thumb-inner {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 10px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
light-dark(#dfdfdf, #363636) 0%,
|
||||||
|
light-dark(#c0c0c0, #2b2b2b) 50%,
|
||||||
|
light-dark(#808080, #232323) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,6 +24,19 @@ export interface DbAlbum {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DbTrack {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
format: string;
|
||||||
|
has_lyrics: number;
|
||||||
|
last_scanned: number | null;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
let db: Database | null = null;
|
let db: Database | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,3 +244,82 @@ export async function getLibraryStats(): Promise<{
|
|||||||
trackCount: trackResult[0]?.total || 0
|
trackCount: trackResult[0]?.total || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tracks without lyrics (has_lyrics = 0)
|
||||||
|
*/
|
||||||
|
export async function getTracksWithoutLyrics(): Promise<DbTrack[]> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const tracks = await database.select<DbTrack[]>(
|
||||||
|
'SELECT * FROM tracks WHERE has_lyrics = 0 ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE'
|
||||||
|
);
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a track (insert or update)
|
||||||
|
*/
|
||||||
|
export async function upsertTrack(track: {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
format: string;
|
||||||
|
has_lyrics: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
await database.execute(
|
||||||
|
`INSERT INTO tracks (path, title, artist, album, duration, format, has_lyrics, last_scanned)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
|
title = $2,
|
||||||
|
artist = $3,
|
||||||
|
album = $4,
|
||||||
|
duration = $5,
|
||||||
|
format = $6,
|
||||||
|
has_lyrics = $7,
|
||||||
|
last_scanned = $8`,
|
||||||
|
[
|
||||||
|
track.path,
|
||||||
|
track.title,
|
||||||
|
track.artist,
|
||||||
|
track.album,
|
||||||
|
track.duration,
|
||||||
|
track.format,
|
||||||
|
track.has_lyrics ? 1 : 0,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last scan timestamp for lyrics
|
||||||
|
*/
|
||||||
|
export async function getLyricsScanTimestamp(): Promise<number | null> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const result = await database.select<{ last_scanned: number | null }[]>(
|
||||||
|
'SELECT MAX(last_scanned) as last_scanned FROM tracks'
|
||||||
|
);
|
||||||
|
return result[0]?.last_scanned || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete tracks that are no longer in the provided paths
|
||||||
|
*/
|
||||||
|
export async function deleteTracksNotInPaths(paths: string[]): Promise<void> {
|
||||||
|
if (paths.length === 0) {
|
||||||
|
const database = await initDatabase();
|
||||||
|
await database.execute('DELETE FROM tracks');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await initDatabase();
|
||||||
|
const placeholders = paths.map((_, i) => `$${i + 1}`).join(',');
|
||||||
|
await database.execute(
|
||||||
|
`DELETE FROM tracks WHERE path NOT IN (${placeholders})`,
|
||||||
|
paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export async function getCachedArtists(): Promise<DeezerArtist[]> {
|
|||||||
export async function getCachedTracks(): Promise<DeezerTrack[]> {
|
export async function getCachedTracks(): Promise<DeezerTrack[]> {
|
||||||
const database = await initDeezerDatabase();
|
const database = await initDeezerDatabase();
|
||||||
const tracks = await database.select<DeezerTrack[]>(
|
const tracks = await database.select<DeezerTrack[]>(
|
||||||
'SELECT * FROM deezer_tracks ORDER BY title COLLATE NOCASE'
|
'SELECT * FROM deezer_tracks ORDER BY ROWID DESC'
|
||||||
);
|
);
|
||||||
return tracks || [];
|
return tracks || [];
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ export async function upsertPlaylists(playlists: any[]): Promise<void> {
|
|||||||
String(playlist.PLAYLIST_ID),
|
String(playlist.PLAYLIST_ID),
|
||||||
playlist.TITLE || '',
|
playlist.TITLE || '',
|
||||||
playlist.NB_SONG || 0,
|
playlist.NB_SONG || 0,
|
||||||
playlist.PARENT_USERNAME || 'Unknown',
|
playlist.PARENT_USERNAME || playlist._USER_NAME_FALLBACK || 'Unknown',
|
||||||
playlist.PLAYLIST_PICTURE || null,
|
playlist.PLAYLIST_PICTURE || null,
|
||||||
playlist.PICTURE_TYPE || null,
|
playlist.PICTURE_TYPE || null,
|
||||||
now
|
now
|
||||||
|
|||||||
173
src/lib/library/lyricScanner.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Library scanner for tracks without lyrics files
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readDir, exists, readFile } from '@tauri-apps/plugin-fs';
|
||||||
|
import { parseBuffer } from 'music-metadata';
|
||||||
|
import type { AudioFormat } from '$lib/types/track';
|
||||||
|
import { upsertTrack, getTracksWithoutLyrics, type DbTrack } from '$lib/library/database';
|
||||||
|
|
||||||
|
export interface TrackWithoutLyrics {
|
||||||
|
path: string;
|
||||||
|
filename: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number; // in seconds
|
||||||
|
format: AudioFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a track has an accompanying .lrc file
|
||||||
|
*/
|
||||||
|
async function hasLyricsFile(audioFilePath: string): Promise<boolean> {
|
||||||
|
const lrcPath = audioFilePath.replace(/\.[^.]+$/, '.lrc');
|
||||||
|
return await exists(lrcPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio format from file extension
|
||||||
|
*/
|
||||||
|
function getAudioFormat(filename: string): AudioFormat {
|
||||||
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
|
switch (ext) {
|
||||||
|
case 'flac':
|
||||||
|
return 'flac';
|
||||||
|
case 'mp3':
|
||||||
|
return 'mp3';
|
||||||
|
case 'opus':
|
||||||
|
return 'opus';
|
||||||
|
case 'ogg':
|
||||||
|
return 'ogg';
|
||||||
|
case 'm4a':
|
||||||
|
return 'm4a';
|
||||||
|
case 'wav':
|
||||||
|
return 'wav';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a single directory for audio files without lyrics
|
||||||
|
*/
|
||||||
|
async function scanDirectoryForMissingLyrics(
|
||||||
|
dirPath: string,
|
||||||
|
results: TrackWithoutLyrics[]
|
||||||
|
): Promise<void> {
|
||||||
|
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await readDir(dirPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = `${dirPath}/${entry.name}`;
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
// Recursively scan subdirectories
|
||||||
|
await scanDirectoryForMissingLyrics(fullPath, results);
|
||||||
|
} else {
|
||||||
|
// Check if it's an audio file
|
||||||
|
const hasAudioExt = audioExtensions.some(ext =>
|
||||||
|
entry.name.toLowerCase().endsWith(ext)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAudioExt) {
|
||||||
|
// Check if it has a .lrc file
|
||||||
|
const hasLyrics = await hasLyricsFile(fullPath);
|
||||||
|
|
||||||
|
if (!hasLyrics) {
|
||||||
|
// Read metadata
|
||||||
|
try {
|
||||||
|
const fileData = await readFile(fullPath);
|
||||||
|
const metadata = await parseBuffer(
|
||||||
|
fileData,
|
||||||
|
{ mimeType: `audio/${getAudioFormat(entry.name)}` },
|
||||||
|
{ duration: true, skipCovers: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = metadata.common.title || entry.name.replace(/\.[^.]+$/, '');
|
||||||
|
const artist = metadata.common.artist || metadata.common.albumartist || 'Unknown Artist';
|
||||||
|
const album = metadata.common.album || 'Unknown Album';
|
||||||
|
const duration = metadata.format.duration || 0;
|
||||||
|
|
||||||
|
// Only add if we have minimum required metadata
|
||||||
|
if (title && artist && album && duration > 0) {
|
||||||
|
results.push({
|
||||||
|
path: fullPath,
|
||||||
|
filename: entry.name,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
duration,
|
||||||
|
format: getAudioFormat(entry.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[LyricScanner] Could not read metadata for ${fullPath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[LyricScanner] Error scanning directory ${dirPath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the music library for tracks without .lrc files
|
||||||
|
* Results are cached in the database
|
||||||
|
*/
|
||||||
|
export async function scanForTracksWithoutLyrics(
|
||||||
|
musicFolderPath: string,
|
||||||
|
onProgress?: (current: number, total: number, message: string) => void
|
||||||
|
): Promise<TrackWithoutLyrics[]> {
|
||||||
|
const results: TrackWithoutLyrics[] = [];
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(0, 0, 'Scanning for tracks without lyrics...');
|
||||||
|
}
|
||||||
|
|
||||||
|
await scanDirectoryForMissingLyrics(musicFolderPath, results);
|
||||||
|
|
||||||
|
// Save results to database
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(results.length, results.length, 'Caching results...');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of results) {
|
||||||
|
await upsertTrack({
|
||||||
|
path: track.path,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: Math.round(track.duration),
|
||||||
|
format: track.format,
|
||||||
|
has_lyrics: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cached tracks without lyrics from database
|
||||||
|
*/
|
||||||
|
export async function loadCachedTracksWithoutLyrics(): Promise<TrackWithoutLyrics[]> {
|
||||||
|
const dbTracks = await getTracksWithoutLyrics();
|
||||||
|
|
||||||
|
return dbTracks.map((track: DbTrack) => ({
|
||||||
|
path: track.path,
|
||||||
|
filename: track.path.split('/').pop() || track.path,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: track.duration,
|
||||||
|
format: track.format as AudioFormat
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||||
|
import { encodeEmojis } from '$lib/utils/emoji';
|
||||||
|
|
||||||
export interface M3U8Track {
|
export interface M3U8Track {
|
||||||
duration: number; // in seconds
|
duration: number; // in seconds
|
||||||
@@ -22,14 +23,15 @@ export async function writeM3U8(
|
|||||||
tracks: M3U8Track[],
|
tracks: M3U8Track[],
|
||||||
playlistsFolder: string
|
playlistsFolder: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Sanitize playlist name for filename
|
// Encode emojis and sanitize playlist name for filename
|
||||||
const sanitizedName = sanitizeFilename(playlistName);
|
const encodedName = encodeEmojis(playlistName);
|
||||||
|
const sanitizedName = sanitizeFilename(encodedName);
|
||||||
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
|
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
|
||||||
|
|
||||||
// Build m3u8 content
|
// Build m3u8 content
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'#EXTM3U',
|
'#EXTM3U',
|
||||||
`#PLAYLIST:${playlistName}`,
|
`#PLAYLIST:${encodedName}`,
|
||||||
'#EXTENC:UTF-8',
|
'#EXTENC:UTF-8',
|
||||||
''
|
''
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { readTextFile, exists, readDir } from '@tauri-apps/plugin-fs';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
||||||
import { findAlbumArt } from './album';
|
import { findAlbumArt } from './album';
|
||||||
|
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||||
|
import { decodeEmojis } from '$lib/utils/emoji';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get audio format from file extension
|
* Get audio format from file extension
|
||||||
@@ -35,6 +37,30 @@ export interface ParsedPlaylistTrack {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract playlist name from #PLAYLIST: metadata line in m3u8 file
|
||||||
|
* Returns decoded emoji name, or undefined if not found
|
||||||
|
*/
|
||||||
|
export async function parsePlaylistName(playlistPath: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await readTextFile(playlistPath);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('#PLAYLIST:')) {
|
||||||
|
const encodedName = trimmed.substring('#PLAYLIST:'.length);
|
||||||
|
return decodeEmojis(encodedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading playlist name:', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse M3U/M3U8 playlist file
|
* Parse M3U/M3U8 playlist file
|
||||||
* Supports both basic M3U and extended M3U8 format
|
* Supports both basic M3U and extended M3U8 format
|
||||||
@@ -51,7 +77,7 @@ export async function parsePlaylist(playlistPath: string): Promise<ParsedPlaylis
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
|
|
||||||
// Skip empty lines and non-EXTINF comments
|
// Skip empty lines and comments (except EXTINF)
|
||||||
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
|
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -228,7 +254,8 @@ export async function findPlaylistCoverFallback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Construct album folder path following the same structure as downloader
|
// Construct album folder path following the same structure as downloader
|
||||||
const albumPath = `${musicFolder}/${albumArtist}/${album}`;
|
// Must use sanitized paths to match how files are actually saved on disk
|
||||||
|
const albumPath = `${musicFolder}/${sanitizeFilename(albumArtist)}/${sanitizeFilename(album)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if album folder exists and has cover art
|
// Check if album folder exists and has cover art
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readDir, readFile } from '@tauri-apps/plugin-fs';
|
import { readDir, readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
|
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
|
||||||
|
import { parsePlaylistName } from './playlist';
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -283,11 +284,18 @@ export async function scanPlaylists(playlistsFolderPath: string): Promise<Playli
|
|||||||
if (!entry.isDirectory) {
|
if (!entry.isDirectory) {
|
||||||
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
|
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
|
||||||
if (isPlaylist) {
|
if (isPlaylist) {
|
||||||
// Remove extension for display name
|
const playlistPath = `${playlistsFolderPath}/${entry.name}`;
|
||||||
|
|
||||||
|
// Try to read playlist name from #PLAYLIST: metadata (with emoji decoding)
|
||||||
|
const metadataName = await parsePlaylistName(playlistPath);
|
||||||
|
|
||||||
|
// Fallback to filename without extension if no metadata found
|
||||||
const nameWithoutExt = entry.name.replace(/\.(m3u8?|M3U8?)$/, '');
|
const nameWithoutExt = entry.name.replace(/\.(m3u8?|M3U8?)$/, '');
|
||||||
|
const displayName = metadataName || nameWithoutExt;
|
||||||
|
|
||||||
playlists.push({
|
playlists.push({
|
||||||
name: nameWithoutExt,
|
name: displayName,
|
||||||
path: `${playlistsFolderPath}/${entry.name}`
|
path: playlistPath
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
343
src/lib/library/spotify-database.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import Database from '@tauri-apps/plugin-sql';
|
||||||
|
import { remove } from '@tauri-apps/plugin-fs';
|
||||||
|
import { appConfigDir } from '@tauri-apps/api/path';
|
||||||
|
|
||||||
|
export interface SpotifyPlaylist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
track_count: number;
|
||||||
|
owner_name: string;
|
||||||
|
image_url?: string;
|
||||||
|
cached_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyAlbum {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist_name: string;
|
||||||
|
track_count: number;
|
||||||
|
release_date?: string;
|
||||||
|
image_url?: string;
|
||||||
|
cached_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyArtist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
followers: number;
|
||||||
|
image_url?: string;
|
||||||
|
cached_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyTrack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
isrc?: string | null;
|
||||||
|
album_image_url?: string | null;
|
||||||
|
cached_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotifyPlaylistTrack {
|
||||||
|
id: number;
|
||||||
|
playlist_id: string;
|
||||||
|
track_id: string;
|
||||||
|
name: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
track_number: number | null;
|
||||||
|
isrc?: string | null;
|
||||||
|
cached_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: Database | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize database connection
|
||||||
|
*/
|
||||||
|
export async function initSpotifyDatabase(): Promise<Database> {
|
||||||
|
if (!db) {
|
||||||
|
db = await Database.load('sqlite:spotify.db');
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close database connection (for cache clearing)
|
||||||
|
*/
|
||||||
|
export async function closeSpotifyDatabase(): Promise<void> {
|
||||||
|
if (db) {
|
||||||
|
await db.close();
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached playlists
|
||||||
|
*/
|
||||||
|
export async function getCachedPlaylists(): Promise<SpotifyPlaylist[]> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const playlists = await database.select<SpotifyPlaylist[]>(
|
||||||
|
'SELECT * FROM spotify_playlists ORDER BY name COLLATE NOCASE'
|
||||||
|
);
|
||||||
|
return playlists || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached albums
|
||||||
|
*/
|
||||||
|
export async function getCachedAlbums(): Promise<SpotifyAlbum[]> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const albums = await database.select<SpotifyAlbum[]>(
|
||||||
|
'SELECT * FROM spotify_albums ORDER BY artist_name COLLATE NOCASE, name COLLATE NOCASE'
|
||||||
|
);
|
||||||
|
return albums || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached artists
|
||||||
|
*/
|
||||||
|
export async function getCachedArtists(): Promise<SpotifyArtist[]> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const artists = await database.select<SpotifyArtist[]>(
|
||||||
|
'SELECT * FROM spotify_artists ORDER BY name COLLATE NOCASE'
|
||||||
|
);
|
||||||
|
return artists || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached tracks
|
||||||
|
*/
|
||||||
|
export async function getCachedTracks(): Promise<SpotifyTrack[]> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const tracks = await database.select<SpotifyTrack[]>(
|
||||||
|
'SELECT * FROM spotify_tracks ORDER BY ROWID ASC'
|
||||||
|
);
|
||||||
|
return tracks || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert playlists
|
||||||
|
*/
|
||||||
|
export async function upsertPlaylists(playlists: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('[spotify-database] Upserting playlists, count:', playlists.length);
|
||||||
|
if (playlists.length > 0) {
|
||||||
|
console.log('[spotify-database] First playlist sample:', playlists[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Clear existing playlists
|
||||||
|
await database.execute('DELETE FROM spotify_playlists');
|
||||||
|
console.log('[spotify-database] Cleared existing playlists');
|
||||||
|
|
||||||
|
// Insert new playlists
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
await database.execute(
|
||||||
|
`INSERT INTO spotify_playlists (id, name, track_count, owner_name, image_url, cached_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
playlist.id,
|
||||||
|
playlist.name || '',
|
||||||
|
playlist.tracks?.total || 0,
|
||||||
|
playlist.owner?.display_name || 'Unknown',
|
||||||
|
playlist.images?.[0]?.url || null,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('[spotify-database] Inserted', playlists.length, 'playlists');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[spotify-database] Error in upsertPlaylists:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert albums
|
||||||
|
*/
|
||||||
|
export async function upsertAlbums(albums: any[]): Promise<void> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Clear existing albums
|
||||||
|
await database.execute('DELETE FROM spotify_albums');
|
||||||
|
|
||||||
|
// Insert new albums
|
||||||
|
for (const album of albums) {
|
||||||
|
await database.execute(
|
||||||
|
`INSERT INTO spotify_albums (id, name, artist_name, track_count, release_date, image_url, cached_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[
|
||||||
|
album.album.id,
|
||||||
|
album.album.name || '',
|
||||||
|
album.album.artists?.[0]?.name || 'Unknown',
|
||||||
|
album.album.total_tracks || 0,
|
||||||
|
album.album.release_date || null,
|
||||||
|
album.album.images?.[0]?.url || null,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert artists
|
||||||
|
*/
|
||||||
|
export async function upsertArtists(artists: any[]): Promise<void> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Clear existing artists
|
||||||
|
await database.execute('DELETE FROM spotify_artists');
|
||||||
|
|
||||||
|
// Insert new artists
|
||||||
|
for (const artist of artists) {
|
||||||
|
await database.execute(
|
||||||
|
`INSERT INTO spotify_artists (id, name, followers, image_url, cached_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[
|
||||||
|
artist.id,
|
||||||
|
artist.name || '',
|
||||||
|
artist.followers?.total || 0,
|
||||||
|
artist.images?.[0]?.url || null,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert tracks
|
||||||
|
*/
|
||||||
|
export async function upsertTracks(tracks: any[]): Promise<void> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Clear existing tracks
|
||||||
|
await database.execute('DELETE FROM spotify_tracks');
|
||||||
|
|
||||||
|
// Insert new tracks
|
||||||
|
for (const track of tracks) {
|
||||||
|
await database.execute(
|
||||||
|
`INSERT INTO spotify_tracks (id, name, artist_name, album_name, duration_ms, isrc, album_image_url, cached_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
track.track.id,
|
||||||
|
track.track.name || '',
|
||||||
|
track.track.artists?.[0]?.name || 'Unknown',
|
||||||
|
track.track.album?.name || '',
|
||||||
|
track.track.duration_ms || 0,
|
||||||
|
track.track.external_ids?.isrc || null,
|
||||||
|
track.track.album?.images?.[0]?.url || null,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache timestamp
|
||||||
|
*/
|
||||||
|
export async function getCacheTimestamp(): Promise<number | null> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const result = await database.select<{ cached_at: number }[]>(
|
||||||
|
'SELECT cached_at FROM spotify_playlists LIMIT 1'
|
||||||
|
);
|
||||||
|
return result[0]?.cached_at || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached playlist tracks
|
||||||
|
*/
|
||||||
|
export async function getCachedPlaylistTracks(playlistId: string): Promise<SpotifyPlaylistTrack[]> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const tracks = await database.select<SpotifyPlaylistTrack[]>(
|
||||||
|
'SELECT * FROM spotify_playlist_tracks WHERE playlist_id = $1 ORDER BY track_number, id',
|
||||||
|
[playlistId]
|
||||||
|
);
|
||||||
|
return tracks || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single playlist by ID
|
||||||
|
*/
|
||||||
|
export async function getCachedPlaylist(playlistId: string): Promise<SpotifyPlaylist | null> {
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const playlists = await database.select<SpotifyPlaylist[]>(
|
||||||
|
'SELECT * FROM spotify_playlists WHERE id = $1',
|
||||||
|
[playlistId]
|
||||||
|
);
|
||||||
|
return playlists?.[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert playlist tracks
|
||||||
|
*/
|
||||||
|
export async function upsertPlaylistTracks(playlistId: string, tracks: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('[spotify-database] Upserting playlist tracks, playlistId:', playlistId, 'count:', tracks.length);
|
||||||
|
|
||||||
|
const database = await initSpotifyDatabase();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Clear existing tracks for this playlist
|
||||||
|
await database.execute('DELETE FROM spotify_playlist_tracks WHERE playlist_id = $1', [playlistId]);
|
||||||
|
console.log('[spotify-database] Cleared existing tracks for playlist:', playlistId);
|
||||||
|
|
||||||
|
// Insert new tracks
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
const item = tracks[i];
|
||||||
|
const track = item.track;
|
||||||
|
|
||||||
|
await database.execute(
|
||||||
|
`INSERT INTO spotify_playlist_tracks (playlist_id, track_id, name, artist_name, album_name, duration_ms, track_number, isrc, cached_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
playlistId,
|
||||||
|
track.id,
|
||||||
|
track.name || '',
|
||||||
|
track.artists?.[0]?.name || 'Unknown',
|
||||||
|
track.album?.name || '',
|
||||||
|
track.duration_ms || 0,
|
||||||
|
i + 1, // Use position in playlist as track number
|
||||||
|
track.external_ids?.isrc || null,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('[spotify-database] Inserted', tracks.length, 'tracks for playlist:', playlistId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[spotify-database] Error in upsertPlaylistTracks:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all Spotify cache
|
||||||
|
*/
|
||||||
|
export async function clearSpotifyCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Close the database connection
|
||||||
|
await closeSpotifyDatabase();
|
||||||
|
|
||||||
|
// Delete the entire database file
|
||||||
|
const configDir = await appConfigDir();
|
||||||
|
const dbPath = `${configDir}/spotify.db`;
|
||||||
|
|
||||||
|
await remove(dbPath);
|
||||||
|
|
||||||
|
// Reinitialize the database (this will run migrations)
|
||||||
|
await initSpotifyDatabase();
|
||||||
|
|
||||||
|
console.log('[spotify-database] Spotify database file deleted and recreated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[spotify-database] Error clearing cache:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/lib/services/audioPlayer.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
|
import { playback } from '$lib/stores/playback';
|
||||||
|
import type { Track } from '$lib/types/track';
|
||||||
|
|
||||||
|
class AudioPlayer {
|
||||||
|
private audio: HTMLAudioElement | null = null;
|
||||||
|
private currentTrackPath: string | null = null;
|
||||||
|
private isSeeking = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
this.audio = new Audio();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners() {
|
||||||
|
if (!this.audio) return;
|
||||||
|
|
||||||
|
// Time updates
|
||||||
|
this.audio.addEventListener('timeupdate', () => {
|
||||||
|
if (this.audio && !this.isSeeking) {
|
||||||
|
playback.setCurrentTime(this.audio.currentTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seeking events
|
||||||
|
this.audio.addEventListener('seeking', () => {
|
||||||
|
this.isSeeking = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.audio.addEventListener('seeked', () => {
|
||||||
|
this.isSeeking = false;
|
||||||
|
if (this.audio) {
|
||||||
|
playback.setCurrentTime(this.audio.currentTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Duration loaded
|
||||||
|
this.audio.addEventListener('loadedmetadata', () => {
|
||||||
|
if (this.audio) {
|
||||||
|
playback.setDuration(this.audio.duration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track ended - auto-advance
|
||||||
|
this.audio.addEventListener('ended', () => {
|
||||||
|
console.log('[AudioPlayer] Track ended, advancing to next');
|
||||||
|
playback.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
this.audio.addEventListener('error', (e) => {
|
||||||
|
console.error('[AudioPlayer] Playback error:', e);
|
||||||
|
const error = this.audio?.error;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`[AudioPlayer] Error code: ${error.code}, message: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip to next track on error
|
||||||
|
console.log('[AudioPlayer] Skipping to next track due to error');
|
||||||
|
playback.next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTrack(track: Track) {
|
||||||
|
if (!this.audio) {
|
||||||
|
console.error('[AudioPlayer] Audio element not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert file path to Tauri asset URL
|
||||||
|
const audioUrl = convertFileSrc(track.path);
|
||||||
|
|
||||||
|
console.log('[AudioPlayer] Loading track:', track.metadata.title || track.filename);
|
||||||
|
console.log('[AudioPlayer] File path:', track.path);
|
||||||
|
console.log('[AudioPlayer] Asset URL:', audioUrl);
|
||||||
|
|
||||||
|
// Only reload if different track
|
||||||
|
if (this.currentTrackPath !== track.path) {
|
||||||
|
this.audio.src = audioUrl;
|
||||||
|
this.currentTrackPath = track.path;
|
||||||
|
|
||||||
|
// Check for LRC file (same path but .lrc extension)
|
||||||
|
const lrcPath = track.path.replace(/\.(flac|mp3|opus|ogg|m4a|wav)$/i, '.lrc');
|
||||||
|
playback.setLrcPath(lrcPath);
|
||||||
|
|
||||||
|
await this.audio.load();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AudioPlayer] Error loading track:', error);
|
||||||
|
// Skip to next on load error
|
||||||
|
playback.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async play() {
|
||||||
|
if (!this.audio) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.audio.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AudioPlayer] Error playing:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
if (!this.audio) return;
|
||||||
|
this.audio.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(time: number) {
|
||||||
|
if (!this.audio) return;
|
||||||
|
this.audio.currentTime = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume: number) {
|
||||||
|
if (!this.audio) return;
|
||||||
|
this.audio.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentTime(): number {
|
||||||
|
return this.audio?.currentTime || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDuration(): number {
|
||||||
|
return this.audio?.duration || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const audioPlayer = new AudioPlayer();
|
||||||
|
|
||||||
|
// Watch playback state and control audio
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
let prevTrack: Track | null = null;
|
||||||
|
let prevIsPlaying = false;
|
||||||
|
let prevVolume = 1;
|
||||||
|
|
||||||
|
playback.subscribe(state => {
|
||||||
|
const { currentTrack, isPlaying, volume } = state;
|
||||||
|
|
||||||
|
// Track changed
|
||||||
|
if (currentTrack && currentTrack !== prevTrack) {
|
||||||
|
audioPlayer.loadTrack(currentTrack).then(() => {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioPlayer.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
prevTrack = currentTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play/pause state changed
|
||||||
|
if (isPlaying !== prevIsPlaying) {
|
||||||
|
if (isPlaying && currentTrack) {
|
||||||
|
audioPlayer.play();
|
||||||
|
} else {
|
||||||
|
audioPlayer.pause();
|
||||||
|
}
|
||||||
|
prevIsPlaying = isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume changed
|
||||||
|
if (volume !== prevVolume) {
|
||||||
|
audioPlayer.setVolume(volume);
|
||||||
|
prevVolume = volume;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -340,7 +340,15 @@ export class DeezerAPI {
|
|||||||
nb: -1
|
nb: -1
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.TAB?.playlists?.data || [];
|
const playlists = response.TAB?.playlists?.data || [];
|
||||||
|
const userName = response.DATA?.USER?.BLOG_NAME || 'Unknown';
|
||||||
|
|
||||||
|
// Attach userName to each playlist for use as fallback in database
|
||||||
|
playlists.forEach((playlist: any) => {
|
||||||
|
playlist._USER_NAME_FALLBACK = userName;
|
||||||
|
});
|
||||||
|
|
||||||
|
return playlists;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching playlists:', error);
|
console.error('Error fetching playlists:', error);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { addToQueue } from '$lib/stores/downloadQueue';
|
|||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
import { deezerAuth } from '$lib/stores/deezer';
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
import { trackExists } from './downloader';
|
import { trackExists } from './downloader';
|
||||||
|
import { setInfo, setWarning } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +104,7 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
|
|||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
|
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
|
||||||
|
setWarning(`Skipped: ${track.title} (already exists)`);
|
||||||
return { added: false, reason: 'already_exists' };
|
return { added: false, reason: 'already_exists' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,5 +119,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
|
|||||||
downloadObject: track
|
downloadObject: track
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setInfo(`Queued: ${track.title}`);
|
||||||
return { added: true };
|
return { added: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* Deezer track downloader with streaming and decryption
|
* Deezer track downloader with streaming and decryption
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetch } from '@tauri-apps/plugin-http';
|
|
||||||
import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs';
|
import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { generateTrackPath } from './paths';
|
import { generateTrackPath } from './paths';
|
||||||
@@ -32,6 +31,7 @@ export async function downloadTrack(
|
|||||||
retryCount: number = 0,
|
retryCount: number = 0,
|
||||||
decryptionTrackId?: string
|
decryptionTrackId?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const { listen } = await import('@tauri-apps/api/event');
|
||||||
// Generate paths
|
// Generate paths
|
||||||
const paths = generateTrackPath(track, musicFolder, format, false);
|
const paths = generateTrackPath(track, musicFolder, format, false);
|
||||||
|
|
||||||
@@ -56,85 +56,32 @@ export async function downloadTrack(
|
|||||||
console.log('Temp path:', paths.tempPath);
|
console.log('Temp path:', paths.tempPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch the track with timeout
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout
|
|
||||||
|
|
||||||
const response = await fetch(downloadURL, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36'
|
|
||||||
},
|
|
||||||
signal: controller.signal
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalSize = parseInt(response.headers.get('content-length') || '0');
|
|
||||||
const isCrypted = downloadURL.includes('/mobile/') || downloadURL.includes('/media/');
|
const isCrypted = downloadURL.includes('/mobile/') || downloadURL.includes('/media/');
|
||||||
|
|
||||||
// Stream the response with progress tracking
|
// Use the provided decryption track ID (for fallback tracks) or the original track ID
|
||||||
const reader = response.body!.getReader();
|
const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString();
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
let downloadedBytes = 0;
|
|
||||||
let lastReportedPercentage = 0;
|
|
||||||
|
|
||||||
while (true) {
|
// Set up progress listener
|
||||||
const { done, value } = await reader.read();
|
const unlisten = await listen<DownloadProgress>('download-progress', (event) => {
|
||||||
if (done) break;
|
if (onProgress) {
|
||||||
|
onProgress(event.payload);
|
||||||
chunks.push(value);
|
|
||||||
downloadedBytes += value.length;
|
|
||||||
|
|
||||||
// Call progress callback every 5%
|
|
||||||
if (onProgress && totalSize > 0) {
|
|
||||||
const percentage = (downloadedBytes / totalSize) * 100;
|
|
||||||
const roundedPercentage = Math.floor(percentage / 5) * 5;
|
|
||||||
|
|
||||||
if (roundedPercentage > lastReportedPercentage || percentage === 100) {
|
|
||||||
lastReportedPercentage = roundedPercentage;
|
|
||||||
onProgress({
|
|
||||||
downloaded: downloadedBytes,
|
|
||||||
total: totalSize,
|
|
||||||
percentage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// Combine chunks into single Uint8Array
|
try {
|
||||||
const encryptedData = new Uint8Array(downloadedBytes);
|
// Download and decrypt in Rust backend (streaming, no memory accumulation)
|
||||||
let offset = 0;
|
console.log('Downloading and decrypting track in Rust backend...');
|
||||||
for (const chunk of chunks) {
|
await invoke('download_and_decrypt_track', {
|
||||||
encryptedData.set(chunk, offset);
|
url: downloadURL,
|
||||||
offset += chunk.length;
|
trackId: trackIdForDecryption,
|
||||||
}
|
outputPath: paths.tempPath,
|
||||||
|
isEncrypted: isCrypted
|
||||||
console.log(`Downloaded ${encryptedData.length} bytes, encrypted: ${isCrypted}`);
|
|
||||||
|
|
||||||
// Yield to the browser to keep UI responsive
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
// Decrypt if needed
|
|
||||||
let decryptedData: Uint8Array;
|
|
||||||
|
|
||||||
if (isCrypted) {
|
|
||||||
console.log('Decrypting track...');
|
|
||||||
// Use the provided decryption track ID (for fallback tracks) or the original track ID
|
|
||||||
const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString();
|
|
||||||
console.log(`Decrypting with track ID: ${trackIdForDecryption}`);
|
|
||||||
// Call Rust decryption function - Tauri returns Vec<u8> as number[]
|
|
||||||
const decryptedArray = await invoke<number[]>('decrypt_deezer_track', {
|
|
||||||
data: encryptedData,
|
|
||||||
trackId: trackIdForDecryption
|
|
||||||
});
|
});
|
||||||
decryptedData = new Uint8Array(decryptedArray);
|
|
||||||
} else {
|
console.log('Download and decryption complete!');
|
||||||
decryptedData = encryptedData;
|
} finally {
|
||||||
|
// Clean up event listener
|
||||||
|
unlisten();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user settings
|
// Get user settings
|
||||||
@@ -151,10 +98,7 @@ export async function downloadTrack(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write untagged file to temp first
|
// File is already written to temp by Rust backend
|
||||||
console.log('Writing untagged file to temp...');
|
|
||||||
await writeFile(paths.tempPath, decryptedData);
|
|
||||||
|
|
||||||
// Move to final location
|
// Move to final location
|
||||||
const finalPath = `${paths.filepath}/${paths.filename}`;
|
const finalPath = `${paths.filepath}/${paths.filename}`;
|
||||||
console.log('Moving to final location:', finalPath);
|
console.log('Moving to final location:', finalPath);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { trackExists } from './downloader';
|
|||||||
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
||||||
import { generateTrackPath } from './paths';
|
import { generateTrackPath } from './paths';
|
||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { setInfo, setSuccess } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { DeezerTrack } from '$lib/types/deezer';
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
import { mkdir } from '@tauri-apps/plugin-fs';
|
import { mkdir } from '@tauri-apps/plugin-fs';
|
||||||
@@ -76,6 +77,15 @@ export async function downloadDeezerPlaylist(
|
|||||||
|
|
||||||
console.log(`[PlaylistDownloader] Queued ${addedCount} tracks, skipped ${skippedCount}`);
|
console.log(`[PlaylistDownloader] Queued ${addedCount} tracks, skipped ${skippedCount}`);
|
||||||
|
|
||||||
|
// Show queue status message
|
||||||
|
if (addedCount > 0) {
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
setInfo(`Queued ${addedCount} track${addedCount !== 1 ? 's' : ''} (${skippedCount} skipped)`);
|
||||||
|
} else {
|
||||||
|
setInfo(`Queued ${addedCount} track${addedCount !== 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate m3u8 file
|
// Generate m3u8 file
|
||||||
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
|
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
|
||||||
// Generate expected path for this track
|
// Generate expected path for this track
|
||||||
@@ -98,5 +108,8 @@ export async function downloadDeezerPlaylist(
|
|||||||
|
|
||||||
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
||||||
|
|
||||||
|
// Show success message for playlist creation
|
||||||
|
setSuccess(`Playlist created: ${playlistName}`);
|
||||||
|
|
||||||
return m3u8Path;
|
return m3u8Path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
import { deezerAuth } from '$lib/stores/deezer';
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
import { syncTrackPaths } from '$lib/library/incrementalSync';
|
import { syncTrackPaths } from '$lib/library/incrementalSync';
|
||||||
|
import { setSuccess, setError } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { DeezerTrack } from '$lib/types/deezer';
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
@@ -121,9 +122,6 @@ export class DeezerQueueManager {
|
|||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
console.log('[DeezerQueueManager] Starting queue processor');
|
console.log('[DeezerQueueManager] Starting queue processor');
|
||||||
|
|
||||||
// Clear any stale currentJob from previous session
|
|
||||||
await setCurrentJob(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.processQueue();
|
await this.processQueue();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -182,12 +180,29 @@ export class DeezerQueueManager {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
progress: 100
|
progress: 100
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
if (nextItem.type === 'track') {
|
||||||
|
setSuccess(`Downloaded: ${nextItem.title}`);
|
||||||
|
} else {
|
||||||
|
const completed = nextItem.completedTracks;
|
||||||
|
const failed = nextItem.failedTracks;
|
||||||
|
if (failed > 0) {
|
||||||
|
setSuccess(`Download complete: ${completed} track${completed !== 1 ? 's' : ''} (${failed} failed)`);
|
||||||
|
} else {
|
||||||
|
setSuccess(`Download complete: ${completed} track${completed !== 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error);
|
console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error);
|
||||||
await updateQueueItem(nextItem.id, {
|
await updateQueueItem(nextItem.id, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setError(`Download failed: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear current job
|
// Clear current job
|
||||||
@@ -197,6 +212,86 @@ export class DeezerQueueManager {
|
|||||||
console.log('[DeezerQueueManager] Queue processor stopped');
|
console.log('[DeezerQueueManager] Queue processor stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure track has cover art URL by fetching album data if needed
|
||||||
|
* Reuses the same logic as addToQueue for consistency
|
||||||
|
*/
|
||||||
|
private async ensureCoverUrl(track: DeezerTrack): Promise<void> {
|
||||||
|
// Skip if already has cover URL
|
||||||
|
if (track.albumCoverUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no album ID to fetch with
|
||||||
|
if (!track.albumId || track.albumId === 0) {
|
||||||
|
console.log(`[DeezerQueueManager] Track "${track.title}" has no albumId, fetching track data...`);
|
||||||
|
|
||||||
|
// Fetch track data to get album ID
|
||||||
|
try {
|
||||||
|
const trackData = await deezerAPI.getTrack(track.id.toString());
|
||||||
|
if (trackData && trackData.ALB_ID) {
|
||||||
|
track.albumId = parseInt(trackData.ALB_ID.toString(), 10);
|
||||||
|
} else {
|
||||||
|
console.warn(`[DeezerQueueManager] Could not get album ID for track "${track.title}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[DeezerQueueManager] Error fetching track data for "${track.title}":`, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch album data for cover art URL
|
||||||
|
try {
|
||||||
|
const albumData = await deezerAPI.getAlbumData(track.albumId.toString());
|
||||||
|
if (albumData?.ALB_PICTURE) {
|
||||||
|
track.albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`;
|
||||||
|
track.albumCoverXlUrl = `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg`;
|
||||||
|
console.log(`[DeezerQueueManager] Fetched cover URL for "${track.title}"`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[DeezerQueueManager] Could not fetch album data for track "${track.title}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure track has lyrics by fetching if needed
|
||||||
|
* Reuses the same logic as addToQueue for consistency
|
||||||
|
*/
|
||||||
|
private async ensureLyrics(track: DeezerTrack): Promise<void> {
|
||||||
|
// Skip if already has lyrics
|
||||||
|
if (track.lyrics) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lyrics from Deezer
|
||||||
|
try {
|
||||||
|
const lyricsData = await deezerAPI.getLyrics(track.id.toString());
|
||||||
|
|
||||||
|
if (lyricsData) {
|
||||||
|
// Parse LRC format (synced lyrics)
|
||||||
|
let syncLrc = '';
|
||||||
|
if (lyricsData.LYRICS_SYNC_JSON) {
|
||||||
|
for (const line of lyricsData.LYRICS_SYNC_JSON) {
|
||||||
|
const text = line.line || '';
|
||||||
|
const timestamp = line.lrc_timestamp || '[00:00.00]';
|
||||||
|
syncLrc += `${timestamp}${text}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
track.lyrics = {
|
||||||
|
sync: syncLrc || undefined,
|
||||||
|
unsync: lyricsData.LYRICS_TEXT || undefined,
|
||||||
|
syncID3: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[DeezerQueueManager] Fetched lyrics for "${track.title}"`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[DeezerQueueManager] Could not fetch lyrics for track "${track.title}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a single track
|
* Download a single track
|
||||||
*/
|
*/
|
||||||
@@ -215,6 +310,16 @@ export class DeezerQueueManager {
|
|||||||
}
|
}
|
||||||
deezerAPI.setArl(authState.arl);
|
deezerAPI.setArl(authState.arl);
|
||||||
|
|
||||||
|
// Ensure track has cover URL if cover art is enabled
|
||||||
|
if (appSettings.embedCoverArt || appSettings.saveCoverToFolder) {
|
||||||
|
await this.ensureCoverUrl(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure track has lyrics if lyrics are enabled
|
||||||
|
if (appSettings.embedLyrics || appSettings.saveLrcFile) {
|
||||||
|
await this.ensureLyrics(track);
|
||||||
|
}
|
||||||
|
|
||||||
// Get user data for license token
|
// Get user data for license token
|
||||||
const userData = await deezerAPI.getUserData();
|
const userData = await deezerAPI.getUserData();
|
||||||
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
||||||
@@ -353,6 +458,16 @@ export class DeezerQueueManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ensure track has cover URL if cover art is enabled
|
||||||
|
if (appSettings.embedCoverArt || appSettings.saveCoverToFolder) {
|
||||||
|
await this.ensureCoverUrl(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure track has lyrics if lyrics are enabled
|
||||||
|
if (appSettings.embedLyrics || appSettings.saveLrcFile) {
|
||||||
|
await this.ensureLyrics(track);
|
||||||
|
}
|
||||||
|
|
||||||
const userData = await deezerAPI.getUserData();
|
const userData = await deezerAPI.getUserData();
|
||||||
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
||||||
|
|
||||||
|
|||||||
107
src/lib/services/deviceSync.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Device sync service layer
|
||||||
|
* Handles communication with Tauri backend for device synchronization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
relativePath: string;
|
||||||
|
size: number;
|
||||||
|
status: 'new' | 'updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStats {
|
||||||
|
newFiles: number;
|
||||||
|
updatedFiles: number;
|
||||||
|
unchangedFiles: number;
|
||||||
|
totalSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncDiff {
|
||||||
|
filesToCopy: FileInfo[];
|
||||||
|
stats: SyncStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncProgress {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
currentFile: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressCallback = (progress: SyncProgress) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index device and compare with library
|
||||||
|
* Returns a diff showing which files need to be synced
|
||||||
|
*/
|
||||||
|
export async function indexAndCompare(
|
||||||
|
libraryPath: string,
|
||||||
|
devicePath: string,
|
||||||
|
overwriteMode: string
|
||||||
|
): Promise<SyncDiff> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<SyncDiff>('index_and_compare', {
|
||||||
|
libraryPath,
|
||||||
|
devicePath,
|
||||||
|
overwriteMode
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error indexing and comparing:', error);
|
||||||
|
throw new Error(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync files to device with progress updates
|
||||||
|
*/
|
||||||
|
export async function syncToDevice(
|
||||||
|
libraryPath: string,
|
||||||
|
devicePath: string,
|
||||||
|
filesToCopy: FileInfo[],
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
): Promise<string> {
|
||||||
|
let unlisten: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set up progress listener
|
||||||
|
unlisten = await listen<SyncProgress>('sync-progress', (event) => {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(event.payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start sync operation
|
||||||
|
const result = await invoke<string>('sync_to_device', {
|
||||||
|
libraryPath,
|
||||||
|
devicePath,
|
||||||
|
filesToCopy
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing to device:', error);
|
||||||
|
throw new Error(String(error));
|
||||||
|
} finally {
|
||||||
|
// Clean up event listener
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human-readable string
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
197
src/lib/services/lrclib.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* LRCLIB API client for fetching lyrics
|
||||||
|
* https://lrclib.net/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
|
|
||||||
|
const LRCLIB_API_BASE = 'https://lrclib.net/api';
|
||||||
|
const USER_AGENT = 'Shark Music Player v1.0.0 (https://github.com/soulshark)';
|
||||||
|
|
||||||
|
export interface LRCLIBLyrics {
|
||||||
|
id: number;
|
||||||
|
trackName: string;
|
||||||
|
artistName: string;
|
||||||
|
albumName: string;
|
||||||
|
duration: number;
|
||||||
|
instrumental: boolean;
|
||||||
|
plainLyrics: string | null;
|
||||||
|
syncedLyrics: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LRCLIBSearchParams {
|
||||||
|
trackName: string;
|
||||||
|
artistName: string;
|
||||||
|
albumName: string;
|
||||||
|
duration: number; // in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if LRCLIB API is available
|
||||||
|
*/
|
||||||
|
export async function checkApiStatus(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LRCLIB_API_BASE}/get/1`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.ok || response.status === 404; // 404 is fine, means API is up
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LRCLIB] API check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lyrics for a track by its signature
|
||||||
|
* Searches external sources if not in LRCLIB database
|
||||||
|
*/
|
||||||
|
export async function getLyrics(params: LRCLIBSearchParams): Promise<LRCLIBLyrics | null> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
track_name: params.trackName,
|
||||||
|
artist_name: params.artistName,
|
||||||
|
album_name: params.albumName,
|
||||||
|
duration: params.duration.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${LRCLIB_API_BASE}/get?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.log('[LRCLIB] No lyrics found for:', params.trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`LRCLIB API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as LRCLIBLyrics;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LRCLIB] Error fetching lyrics:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lyrics from cache only (no external search)
|
||||||
|
*/
|
||||||
|
export async function getLyricsCached(params: LRCLIBSearchParams): Promise<LRCLIBLyrics | null> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
track_name: params.trackName,
|
||||||
|
artist_name: params.artistName,
|
||||||
|
album_name: params.albumName,
|
||||||
|
duration: params.duration.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${LRCLIB_API_BASE}/get-cached?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`LRCLIB API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as LRCLIBLyrics;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LRCLIB] Error fetching cached lyrics:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for lyrics by keywords
|
||||||
|
*/
|
||||||
|
export async function searchLyrics(query: string): Promise<LRCLIBLyrics[]> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams({ q: query });
|
||||||
|
|
||||||
|
const response = await fetch(`${LRCLIB_API_BASE}/search?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`LRCLIB API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as LRCLIBLyrics[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LRCLIB] Error searching lyrics:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save lyrics as .lrc file next to the audio file
|
||||||
|
*/
|
||||||
|
export async function saveLyricsFile(audioFilePath: string, lyrics: string): Promise<void> {
|
||||||
|
const lrcPath = audioFilePath.replace(/\.[^.]+$/, '.lrc');
|
||||||
|
await writeFile(lrcPath, new TextEncoder().encode(lyrics));
|
||||||
|
console.log('[LRCLIB] Saved lyrics to:', lrcPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and save lyrics for a track
|
||||||
|
* Returns true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
export async function fetchAndSaveLyrics(
|
||||||
|
trackPath: string,
|
||||||
|
metadata: {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number; // in seconds
|
||||||
|
}
|
||||||
|
): Promise<{ success: boolean; hasLyrics: boolean; instrumental: boolean }> {
|
||||||
|
try {
|
||||||
|
const lyrics = await getLyrics({
|
||||||
|
trackName: metadata.title,
|
||||||
|
artistName: metadata.artist,
|
||||||
|
albumName: metadata.album,
|
||||||
|
duration: Math.round(metadata.duration)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lyrics) {
|
||||||
|
return { success: false, hasLyrics: false, instrumental: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lyrics.instrumental) {
|
||||||
|
return { success: true, hasLyrics: false, instrumental: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer synced lyrics, fall back to plain lyrics
|
||||||
|
const lyricsText = lyrics.syncedLyrics || lyrics.plainLyrics;
|
||||||
|
|
||||||
|
if (!lyricsText) {
|
||||||
|
return { success: false, hasLyrics: false, instrumental: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveLyricsFile(trackPath, lyricsText);
|
||||||
|
return { success: true, hasLyrics: true, instrumental: false };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LRCLIB] Error fetching and saving lyrics:', error);
|
||||||
|
return { success: false, hasLyrics: false, instrumental: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
411
src/lib/services/spotify.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import type { SpotifyUser } from '$lib/stores/spotify';
|
||||||
|
import { isTokenExpired, saveTokens } from '$lib/stores/spotify';
|
||||||
|
|
||||||
|
const SPOTIFY_AUTH_URL = 'https://accounts.spotify.com/authorize';
|
||||||
|
const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token';
|
||||||
|
const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
|
||||||
|
|
||||||
|
// Required scopes for the app
|
||||||
|
const REQUIRED_SCOPES = [
|
||||||
|
'user-read-private',
|
||||||
|
'user-read-email',
|
||||||
|
'user-library-read',
|
||||||
|
'playlist-read-private',
|
||||||
|
'playlist-read-collaborative',
|
||||||
|
'user-follow-read'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify API client with OAuth 2.0 PKCE flow
|
||||||
|
*/
|
||||||
|
export class SpotifyAPI {
|
||||||
|
private clientId: string | null = null;
|
||||||
|
private clientSecret: string | null = null;
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private refreshToken: string | null = null;
|
||||||
|
private expiresAt: number | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set client credentials (developer app credentials)
|
||||||
|
*/
|
||||||
|
setClientCredentials(clientId: string, clientSecret: string): void {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.clientSecret = clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set OAuth tokens
|
||||||
|
*/
|
||||||
|
setTokens(accessToken: string, refreshToken: string, expiresAt: number): void {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random code verifier for PKCE
|
||||||
|
*/
|
||||||
|
generateCodeVerifier(): string {
|
||||||
|
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const values = crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
return Array.from(values)
|
||||||
|
.map(x => possible[x % possible.length])
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate code challenge from verifier using SHA256
|
||||||
|
*/
|
||||||
|
async generateCodeChallenge(verifier: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(verifier);
|
||||||
|
const hashed = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
|
||||||
|
// Base64 URL encode
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(hashed)))
|
||||||
|
.replace(/=/g, '')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_');
|
||||||
|
|
||||||
|
return base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authorization URL for user to authenticate
|
||||||
|
* Returns the URL and the code verifier (must be stored for later)
|
||||||
|
*/
|
||||||
|
async getAuthorizationUrl(clientId: string, redirectUri: string): Promise<{ url: string; codeVerifier: string }> {
|
||||||
|
const codeVerifier = this.generateCodeVerifier();
|
||||||
|
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
scope: REQUIRED_SCOPES.join(' ')
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${SPOTIFY_AUTH_URL}?${params.toString()}`;
|
||||||
|
|
||||||
|
return { url, codeVerifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for access token
|
||||||
|
*/
|
||||||
|
async exchangeCodeForToken(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
clientId: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(SPOTIFY_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Token exchange error:', errorText);
|
||||||
|
throw new Error(`Token exchange failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.refreshToken = data.refresh_token;
|
||||||
|
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
refresh_token: data.refresh_token,
|
||||||
|
expires_in: data.expires_in
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the access token using the refresh token
|
||||||
|
*/
|
||||||
|
async refreshAccessToken(): Promise<{ access_token: string; expires_in: number }> {
|
||||||
|
if (!this.refreshToken || !this.clientId) {
|
||||||
|
throw new Error('Missing refresh token or client ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: this.refreshToken,
|
||||||
|
client_id: this.clientId
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(SPOTIFY_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Token refresh error:', errorText);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = JSON.parse(errorText);
|
||||||
|
if (errorData.error === 'invalid_grant') {
|
||||||
|
throw new Error('REFRESH_TOKEN_REVOKED');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).message === 'REFRESH_TOKEN_REVOKED') {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Token refresh failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update tokens
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||||
|
|
||||||
|
// Note: Spotify may or may not return a new refresh token
|
||||||
|
const refreshToken = data.refresh_token || this.refreshToken!;
|
||||||
|
if (data.refresh_token) {
|
||||||
|
this.refreshToken = data.refresh_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save refreshed tokens to store
|
||||||
|
await saveTokens(this.accessToken!, refreshToken, data.expires_in);
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
expires_in: data.expires_in
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated API call to Spotify
|
||||||
|
* Automatically refreshes token if expired
|
||||||
|
*/
|
||||||
|
async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
// Check if token needs refresh
|
||||||
|
if (isTokenExpired(this.expiresAt)) {
|
||||||
|
console.log('[Spotify] Token expired, refreshing...');
|
||||||
|
await this.refreshAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.accessToken) {
|
||||||
|
throw new Error('No access token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${SPOTIFY_API_BASE}${endpoint}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`Spotify API error for ${endpoint}:`, errorText);
|
||||||
|
throw new Error(`API call failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's profile
|
||||||
|
*/
|
||||||
|
async getCurrentUser(): Promise<SpotifyUser> {
|
||||||
|
return this.apiCall<SpotifyUser>('/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's playlists
|
||||||
|
*/
|
||||||
|
async getUserPlaylists(limit: number = 50, offset: number = 0): Promise<any> {
|
||||||
|
return this.apiCall(`/me/playlists?limit=${limit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's saved tracks
|
||||||
|
*/
|
||||||
|
async getUserTracks(limit: number = 50, offset: number = 0): Promise<any> {
|
||||||
|
return this.apiCall(`/me/tracks?limit=${limit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's saved albums
|
||||||
|
*/
|
||||||
|
async getUserAlbums(limit: number = 50, offset: number = 0): Promise<any> {
|
||||||
|
return this.apiCall(`/me/albums?limit=${limit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's followed artists
|
||||||
|
*/
|
||||||
|
async getUserArtists(limit: number = 50, after?: string): Promise<any> {
|
||||||
|
const afterParam = after ? `&after=${after}` : '';
|
||||||
|
return this.apiCall(`/me/following?type=artist&limit=${limit}${afterParam}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all user playlists (handles pagination)
|
||||||
|
*/
|
||||||
|
async getAllUserPlaylists(): Promise<any[]> {
|
||||||
|
const allPlaylists: any[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await this.getUserPlaylists(limit, offset);
|
||||||
|
const playlists = response.items || [];
|
||||||
|
|
||||||
|
allPlaylists.push(...playlists);
|
||||||
|
|
||||||
|
if (!response.next || playlists.length < limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Spotify] Fetched', allPlaylists.length, 'playlists');
|
||||||
|
return allPlaylists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all user saved tracks (handles pagination)
|
||||||
|
*/
|
||||||
|
async getAllUserTracks(): Promise<any[]> {
|
||||||
|
const allTracks: any[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await this.getUserTracks(limit, offset);
|
||||||
|
const tracks = response.items || [];
|
||||||
|
|
||||||
|
allTracks.push(...tracks);
|
||||||
|
|
||||||
|
if (!response.next || tracks.length < limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Spotify] Fetched', allTracks.length, 'saved tracks');
|
||||||
|
return allTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all user saved albums (handles pagination)
|
||||||
|
*/
|
||||||
|
async getAllUserAlbums(): Promise<any[]> {
|
||||||
|
const allAlbums: any[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await this.getUserAlbums(limit, offset);
|
||||||
|
const albums = response.items || [];
|
||||||
|
|
||||||
|
allAlbums.push(...albums);
|
||||||
|
|
||||||
|
if (!response.next || albums.length < limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Spotify] Fetched', allAlbums.length, 'saved albums');
|
||||||
|
return allAlbums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all user followed artists (handles pagination)
|
||||||
|
*/
|
||||||
|
async getAllUserArtists(): Promise<any[]> {
|
||||||
|
const allArtists: any[] = [];
|
||||||
|
let after: string | undefined = undefined;
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await this.getUserArtists(limit, after);
|
||||||
|
const artists = response.artists?.items || [];
|
||||||
|
|
||||||
|
allArtists.push(...artists);
|
||||||
|
|
||||||
|
if (!response.artists?.next || artists.length < limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the 'after' cursor from the next URL
|
||||||
|
if (response.artists?.cursors?.after) {
|
||||||
|
after = response.artists.cursors.after;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Spotify] Fetched', allArtists.length, 'followed artists');
|
||||||
|
return allArtists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tracks for a specific playlist (handles pagination)
|
||||||
|
*/
|
||||||
|
async getPlaylistTracks(playlistId: string): Promise<any[]> {
|
||||||
|
const allTracks: any[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 100;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await this.apiCall<any>(`/playlists/${playlistId}/tracks?limit=${limit}&offset=${offset}`);
|
||||||
|
const tracks = response.items || [];
|
||||||
|
|
||||||
|
allTracks.push(...tracks);
|
||||||
|
|
||||||
|
if (!response.next || tracks.length < limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Spotify] Fetched', allTracks.length, 'tracks for playlist', playlistId);
|
||||||
|
return allTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single playlist by ID
|
||||||
|
*/
|
||||||
|
async getPlaylist(playlistId: string): Promise<any> {
|
||||||
|
return this.apiCall<any>(`/playlists/${playlistId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const spotifyAPI = new SpotifyAPI();
|
||||||
216
src/lib/services/spotify/addToQueue.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Utility to add a Spotify track to the download queue by converting it to Deezer
|
||||||
|
* Uses ISRC matching to find the equivalent Deezer track
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { deezerAPI } from '../deezer';
|
||||||
|
import { addToQueue } from '$lib/stores/downloadQueue';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { trackExists } from '../deezer/downloader';
|
||||||
|
import { setInfo, setWarning, setError } from '$lib/stores/status';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
|
||||||
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
|
export interface SpotifyTrackData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
isrc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a Spotify track to the download queue by converting it to Deezer
|
||||||
|
* @param spotifyTrack - Spotify track data (from cache or API)
|
||||||
|
* @returns Result object with success status and details
|
||||||
|
*/
|
||||||
|
export async function addSpotifyTrackToQueue(
|
||||||
|
spotifyTrack: SpotifyTrackData
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
deezerId?: string;
|
||||||
|
matchMethod?: string;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
// Ensure Deezer authentication
|
||||||
|
const authState = get(deezerAuth);
|
||||||
|
if (!authState.loggedIn || !authState.arl) {
|
||||||
|
setError('Deezer login required for downloads');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'deezer_auth_required'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerAPI.setArl(authState.arl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert Spotify track to Deezer
|
||||||
|
console.log(`[AddSpotifyToQueue] Converting: ${spotifyTrack.name} by ${spotifyTrack.artist_name}`);
|
||||||
|
|
||||||
|
const conversionInput: SpotifyTrackInput = {
|
||||||
|
id: spotifyTrack.id,
|
||||||
|
name: spotifyTrack.name,
|
||||||
|
artists: [spotifyTrack.artist_name],
|
||||||
|
album: spotifyTrack.album_name,
|
||||||
|
duration_ms: spotifyTrack.duration_ms,
|
||||||
|
isrc: spotifyTrack.isrc
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
|
||||||
|
|
||||||
|
if (!conversionResult.success || !conversionResult.deezerTrack) {
|
||||||
|
const errorMsg = `Could not find "${spotifyTrack.name}" on Deezer`;
|
||||||
|
console.warn(`[AddSpotifyToQueue] ${errorMsg}`);
|
||||||
|
setWarning(errorMsg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: conversionResult.error || 'conversion_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deezerPublicTrack = conversionResult.deezerTrack;
|
||||||
|
const deezerTrackId = deezerPublicTrack.id.toString();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AddSpotifyToQueue] Matched to Deezer track: ${deezerTrackId} via ${conversionResult.matchMethod}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch full track data from Deezer GW API
|
||||||
|
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
|
||||||
|
|
||||||
|
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
|
||||||
|
const errorMsg = 'Failed to fetch full Deezer track data';
|
||||||
|
console.error(`[AddSpotifyToQueue] ${errorMsg}`);
|
||||||
|
setError(errorMsg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'deezer_fetch_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch album data for cover art
|
||||||
|
let albumData = null;
|
||||||
|
try {
|
||||||
|
albumData = await deezerAPI.getAlbumData(deezerFullTrack.ALB_ID.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AddSpotifyToQueue] Could not fetch album data:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lyrics
|
||||||
|
let lyricsData = null;
|
||||||
|
try {
|
||||||
|
lyricsData = await deezerAPI.getLyrics(deezerFullTrack.SNG_ID.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AddSpotifyToQueue] Could not fetch lyrics:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse lyrics if available
|
||||||
|
let lyrics = undefined;
|
||||||
|
if (lyricsData) {
|
||||||
|
let syncLrc = '';
|
||||||
|
if (lyricsData.LYRICS_SYNC_JSON) {
|
||||||
|
for (const line of lyricsData.LYRICS_SYNC_JSON) {
|
||||||
|
const text = line.line || '';
|
||||||
|
const timestamp = line.lrc_timestamp || '[00:00.00]';
|
||||||
|
syncLrc += `${timestamp}${text}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics = {
|
||||||
|
sync: syncLrc || undefined,
|
||||||
|
unsync: lyricsData.LYRICS_TEXT || undefined,
|
||||||
|
syncID3: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full DeezerTrack object
|
||||||
|
const deezerTrack: DeezerTrack = {
|
||||||
|
id: parseInt(deezerFullTrack.SNG_ID, 10),
|
||||||
|
title: deezerFullTrack.SNG_TITLE,
|
||||||
|
artist: deezerFullTrack.ART_NAME,
|
||||||
|
artistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||||
|
artists: [deezerFullTrack.ART_NAME],
|
||||||
|
album: deezerFullTrack.ALB_TITLE,
|
||||||
|
albumId: parseInt(deezerFullTrack.ALB_ID, 10),
|
||||||
|
albumArtist: deezerFullTrack.ART_NAME,
|
||||||
|
albumArtistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||||
|
trackNumber:
|
||||||
|
typeof deezerFullTrack.TRACK_NUMBER === 'number'
|
||||||
|
? deezerFullTrack.TRACK_NUMBER
|
||||||
|
: parseInt(deezerFullTrack.TRACK_NUMBER, 10),
|
||||||
|
discNumber:
|
||||||
|
typeof deezerFullTrack.DISK_NUMBER === 'number'
|
||||||
|
? deezerFullTrack.DISK_NUMBER
|
||||||
|
: parseInt(deezerFullTrack.DISK_NUMBER, 10),
|
||||||
|
duration:
|
||||||
|
typeof deezerFullTrack.DURATION === 'number'
|
||||||
|
? deezerFullTrack.DURATION
|
||||||
|
: parseInt(deezerFullTrack.DURATION, 10),
|
||||||
|
explicit: deezerFullTrack.EXPLICIT_LYRICS === 1,
|
||||||
|
md5Origin: deezerFullTrack.MD5_ORIGIN,
|
||||||
|
mediaVersion: deezerFullTrack.MEDIA_VERSION,
|
||||||
|
trackToken: deezerFullTrack.TRACK_TOKEN,
|
||||||
|
// Enhanced metadata
|
||||||
|
lyrics,
|
||||||
|
albumCoverUrl: albumData?.ALB_PICTURE
|
||||||
|
? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
|
||||||
|
: undefined,
|
||||||
|
albumCoverXlUrl: albumData?.ALB_PICTURE
|
||||||
|
? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg`
|
||||||
|
: undefined,
|
||||||
|
label: albumData?.LABEL_NAME,
|
||||||
|
barcode: albumData?.UPC,
|
||||||
|
releaseDate: deezerFullTrack.PHYSICAL_RELEASE_DATE,
|
||||||
|
genre: deezerFullTrack.GENRE ? [deezerFullTrack.GENRE] : undefined,
|
||||||
|
copyright: deezerFullTrack.COPYRIGHT
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if we should skip this track (if it exists and overwrite is false)
|
||||||
|
const appSettings = get(settings);
|
||||||
|
|
||||||
|
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
|
||||||
|
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
console.log(`[AddSpotifyToQueue] Skipping "${deezerTrack.title}" - already exists`);
|
||||||
|
setWarning(`Skipped: ${deezerTrack.title} (already exists)`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
deezerId: deezerTrackId,
|
||||||
|
matchMethod: conversionResult.matchMethod,
|
||||||
|
reason: 'already_exists'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
await addToQueue({
|
||||||
|
source: 'deezer',
|
||||||
|
type: 'track',
|
||||||
|
title: deezerTrack.title,
|
||||||
|
artist: deezerTrack.artist,
|
||||||
|
totalTracks: 1,
|
||||||
|
downloadObject: deezerTrack
|
||||||
|
});
|
||||||
|
|
||||||
|
setInfo(`Queued: ${deezerTrack.title}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deezerId: deezerTrackId,
|
||||||
|
matchMethod: conversionResult.matchMethod
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `Error adding track to queue: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
console.error('[AddSpotifyToQueue]', errorMsg);
|
||||||
|
setError(errorMsg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'queue_error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/lib/services/spotify/converter.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Spotify to Deezer track conversion utilities
|
||||||
|
* Matches Spotify tracks to Deezer tracks using ISRC codes (primary) or metadata search (fallback)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import { deezerAPI } from '../deezer';
|
||||||
|
|
||||||
|
export interface SpotifyTrackInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artists: string[];
|
||||||
|
album: string;
|
||||||
|
duration_ms: number;
|
||||||
|
isrc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeezerMatchResult {
|
||||||
|
success: boolean;
|
||||||
|
deezerTrack?: any;
|
||||||
|
matchMethod?: 'isrc' | 'metadata' | 'none';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Deezer for a track by ISRC code
|
||||||
|
* This is the primary and most reliable matching method
|
||||||
|
*/
|
||||||
|
export async function searchDeezerByISRC(isrc: string): Promise<any | null> {
|
||||||
|
if (!isrc || isrc.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[Converter] Searching Deezer by ISRC: ${isrc}`);
|
||||||
|
|
||||||
|
const url = `https://api.deezer.com/2.0/track/isrc:${encodeURIComponent(isrc)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
connectTimeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[Converter] ISRC search failed with status: ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Check if we got an error response
|
||||||
|
if (result.error) {
|
||||||
|
console.warn(`[Converter] ISRC search returned error:`, result.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid track found
|
||||||
|
if (result.id) {
|
||||||
|
console.log(`[Converter] Found Deezer track by ISRC: ${result.id} - ${result.title}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Converter] Error searching by ISRC:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Deezer for a track by metadata (title + artist)
|
||||||
|
* Used as fallback when ISRC is not available or doesn't match
|
||||||
|
*/
|
||||||
|
export async function searchDeezerByMetadata(
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
durationMs?: number
|
||||||
|
): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
// Build search query: "artist title"
|
||||||
|
const query = `${artist} ${title}`.trim();
|
||||||
|
console.log(`[Converter] Searching Deezer by metadata: "${query}"`);
|
||||||
|
|
||||||
|
const searchResults = await deezerAPI.searchTracks(query, 10);
|
||||||
|
|
||||||
|
if (!searchResults.data || searchResults.data.length === 0) {
|
||||||
|
console.warn(`[Converter] No results found for: "${query}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find best match
|
||||||
|
// Priority: exact title match, then duration match (±2 seconds)
|
||||||
|
const durationSec = durationMs ? Math.floor(durationMs / 1000) : undefined;
|
||||||
|
|
||||||
|
for (const track of searchResults.data) {
|
||||||
|
// Check title similarity (case-insensitive)
|
||||||
|
const titleMatch = track.title.toLowerCase() === title.toLowerCase();
|
||||||
|
|
||||||
|
// Check duration if available (within 2 seconds tolerance)
|
||||||
|
const durationMatch = !durationSec || Math.abs(track.duration - durationSec) <= 2;
|
||||||
|
|
||||||
|
if (titleMatch && durationMatch) {
|
||||||
|
console.log(`[Converter] Found exact match by metadata: ${track.id} - ${track.title}`);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match, return first result as best guess
|
||||||
|
const firstResult = searchResults.data[0];
|
||||||
|
console.log(`[Converter] Using first result as best match: ${firstResult.id} - ${firstResult.title}`);
|
||||||
|
return firstResult;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Converter] Error searching by metadata:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Spotify track to Deezer track ID
|
||||||
|
* Uses ISRC matching first, falls back to metadata search
|
||||||
|
*/
|
||||||
|
export async function convertSpotifyTrackToDeezer(
|
||||||
|
spotifyTrack: SpotifyTrackInput
|
||||||
|
): Promise<DeezerMatchResult> {
|
||||||
|
console.log(`[Converter] Converting Spotify track: ${spotifyTrack.name} by ${spotifyTrack.artists.join(', ')}`);
|
||||||
|
|
||||||
|
// Try ISRC matching first (most reliable)
|
||||||
|
if (spotifyTrack.isrc) {
|
||||||
|
const deezerTrack = await searchDeezerByISRC(spotifyTrack.isrc);
|
||||||
|
if (deezerTrack) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deezerTrack,
|
||||||
|
matchMethod: 'isrc'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.log(`[Converter] ISRC match failed for: ${spotifyTrack.isrc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to metadata search
|
||||||
|
const artist = spotifyTrack.artists[0] || 'Unknown';
|
||||||
|
const deezerTrack = await searchDeezerByMetadata(
|
||||||
|
spotifyTrack.name,
|
||||||
|
artist,
|
||||||
|
spotifyTrack.duration_ms
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deezerTrack) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deezerTrack,
|
||||||
|
matchMethod: 'metadata'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match found
|
||||||
|
console.warn(`[Converter] Could not find Deezer match for: ${spotifyTrack.name} by ${artist}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
matchMethod: 'none',
|
||||||
|
error: 'No match found on Deezer'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert multiple Spotify tracks to Deezer track IDs
|
||||||
|
* Returns both successful conversions and failed tracks
|
||||||
|
*/
|
||||||
|
export async function convertSpotifyTracksBatch(
|
||||||
|
spotifyTracks: SpotifyTrackInput[]
|
||||||
|
): Promise<{
|
||||||
|
conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }>;
|
||||||
|
failures: Array<{ spotifyId: string; name: string; artist: string; error: string }>;
|
||||||
|
}> {
|
||||||
|
const conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }> = [];
|
||||||
|
const failures: Array<{ spotifyId: string; name: string; artist: string; error: string }> = [];
|
||||||
|
|
||||||
|
console.log(`[Converter] Converting ${spotifyTracks.length} Spotify tracks to Deezer...`);
|
||||||
|
|
||||||
|
for (const track of spotifyTracks) {
|
||||||
|
try {
|
||||||
|
const result = await convertSpotifyTrackToDeezer(track);
|
||||||
|
|
||||||
|
if (result.success && result.deezerTrack) {
|
||||||
|
conversions.push({
|
||||||
|
spotifyId: track.id,
|
||||||
|
deezerId: result.deezerTrack.id.toString(),
|
||||||
|
matchMethod: result.matchMethod || 'unknown'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
failures.push({
|
||||||
|
spotifyId: track.id,
|
||||||
|
name: track.name,
|
||||||
|
artist: track.artists[0] || 'Unknown',
|
||||||
|
error: result.error || 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Converter] Error converting track ${track.name}:`, error);
|
||||||
|
failures.push({
|
||||||
|
spotifyId: track.id,
|
||||||
|
name: track.name,
|
||||||
|
artist: track.artists[0] || 'Unknown',
|
||||||
|
error: error instanceof Error ? error.message : 'Conversion error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Converter] Conversion complete: ${conversions.length} successful, ${failures.length} failed`);
|
||||||
|
|
||||||
|
return { conversions, failures };
|
||||||
|
}
|
||||||
233
src/lib/services/spotify/playlistDownloader.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Download Spotify playlist - converts tracks to Deezer via ISRC, adds to queue, and creates m3u8 file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addToQueue } from '$lib/stores/downloadQueue';
|
||||||
|
import { trackExists } from '$lib/services/deezer/downloader';
|
||||||
|
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
||||||
|
import { generateTrackPath } from '$lib/services/deezer/paths';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { deezerAPI } from '$lib/services/deezer';
|
||||||
|
import { setInfo, setSuccess, setWarning } from '$lib/stores/status';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { mkdir } from '@tauri-apps/plugin-fs';
|
||||||
|
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
|
||||||
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
|
export interface SpotifyPlaylistTrack {
|
||||||
|
id: number | string;
|
||||||
|
track_id: string;
|
||||||
|
name: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
isrc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a Spotify playlist by converting tracks to Deezer equivalents
|
||||||
|
* - Converts all tracks via ISRC matching
|
||||||
|
* - Adds converted tracks to the download queue (respects overwrite setting)
|
||||||
|
* - Creates an m3u8 playlist file with relative paths
|
||||||
|
*
|
||||||
|
* @param playlistName - Name of the playlist
|
||||||
|
* @param spotifyTracks - Array of Spotify track objects
|
||||||
|
* @param playlistsFolder - Path to playlists folder
|
||||||
|
* @param musicFolder - Path to music folder
|
||||||
|
* @returns Object with m3u8 path and statistics
|
||||||
|
*/
|
||||||
|
export async function downloadSpotifyPlaylist(
|
||||||
|
playlistName: string,
|
||||||
|
spotifyTracks: SpotifyPlaylistTrack[],
|
||||||
|
playlistsFolder: string,
|
||||||
|
musicFolder: string
|
||||||
|
): Promise<{
|
||||||
|
m3u8Path: string;
|
||||||
|
stats: {
|
||||||
|
total: number;
|
||||||
|
queued: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const appSettings = get(settings);
|
||||||
|
const authState = get(deezerAuth);
|
||||||
|
|
||||||
|
// Ensure Deezer is authenticated
|
||||||
|
if (!authState.loggedIn || !authState.arl) {
|
||||||
|
throw new Error('Deezer authentication required for downloads');
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerAPI.setArl(authState.arl);
|
||||||
|
|
||||||
|
console.log(`[SpotifyPlaylistDownloader] Starting download for playlist: ${playlistName}`);
|
||||||
|
console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`);
|
||||||
|
|
||||||
|
// Ensure playlists folder exists
|
||||||
|
try {
|
||||||
|
await mkdir(playlistsFolder, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Folder might already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track statistics
|
||||||
|
let queuedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
// Track successful conversions for m3u8 generation
|
||||||
|
const successfulTracks: Array<{
|
||||||
|
deezerTrack: DeezerTrack;
|
||||||
|
spotifyTrack: SpotifyPlaylistTrack;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Convert and queue each track
|
||||||
|
for (const spotifyTrack of spotifyTracks) {
|
||||||
|
try {
|
||||||
|
// Convert Spotify track to Deezer
|
||||||
|
const conversionInput: SpotifyTrackInput = {
|
||||||
|
id: spotifyTrack.track_id,
|
||||||
|
name: spotifyTrack.name,
|
||||||
|
artists: [spotifyTrack.artist_name],
|
||||||
|
album: spotifyTrack.album_name,
|
||||||
|
duration_ms: spotifyTrack.duration_ms,
|
||||||
|
isrc: spotifyTrack.isrc
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
|
||||||
|
|
||||||
|
if (!conversionResult.success || !conversionResult.deezerTrack) {
|
||||||
|
console.warn(
|
||||||
|
`[SpotifyPlaylistDownloader] Failed to convert: ${spotifyTrack.name} - ${conversionResult.error}`
|
||||||
|
);
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deezerPublicTrack = conversionResult.deezerTrack;
|
||||||
|
|
||||||
|
// Fetch full track data from Deezer GW API (needed for download)
|
||||||
|
const deezerTrackId = deezerPublicTrack.id.toString();
|
||||||
|
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
|
||||||
|
|
||||||
|
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
|
||||||
|
console.warn(`[SpotifyPlaylistDownloader] Could not fetch full Deezer track data for ID: ${deezerTrackId}`);
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DeezerTrack object
|
||||||
|
const deezerTrack: DeezerTrack = {
|
||||||
|
id: parseInt(deezerFullTrack.SNG_ID, 10),
|
||||||
|
title: deezerFullTrack.SNG_TITLE,
|
||||||
|
artist: deezerFullTrack.ART_NAME,
|
||||||
|
artistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||||
|
artists: [deezerFullTrack.ART_NAME],
|
||||||
|
album: deezerFullTrack.ALB_TITLE,
|
||||||
|
albumId: parseInt(deezerFullTrack.ALB_ID, 10),
|
||||||
|
albumArtist: deezerFullTrack.ART_NAME,
|
||||||
|
albumArtistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||||
|
trackNumber:
|
||||||
|
typeof deezerFullTrack.TRACK_NUMBER === 'number'
|
||||||
|
? deezerFullTrack.TRACK_NUMBER
|
||||||
|
: parseInt(deezerFullTrack.TRACK_NUMBER, 10),
|
||||||
|
discNumber:
|
||||||
|
typeof deezerFullTrack.DISK_NUMBER === 'number'
|
||||||
|
? deezerFullTrack.DISK_NUMBER
|
||||||
|
: parseInt(deezerFullTrack.DISK_NUMBER, 10),
|
||||||
|
duration:
|
||||||
|
typeof deezerFullTrack.DURATION === 'number'
|
||||||
|
? deezerFullTrack.DURATION
|
||||||
|
: parseInt(deezerFullTrack.DURATION, 10),
|
||||||
|
explicit: deezerFullTrack.EXPLICIT_LYRICS === 1,
|
||||||
|
md5Origin: deezerFullTrack.MD5_ORIGIN,
|
||||||
|
mediaVersion: deezerFullTrack.MEDIA_VERSION,
|
||||||
|
trackToken: deezerFullTrack.TRACK_TOKEN
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if track already exists (if overwrite is disabled)
|
||||||
|
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
|
||||||
|
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
|
||||||
|
if (exists) {
|
||||||
|
console.log(`[SpotifyPlaylistDownloader] Skipping "${deezerTrack.title}" - already exists`);
|
||||||
|
skippedCount++;
|
||||||
|
// Still add to successful tracks for m3u8 generation
|
||||||
|
successfulTracks.push({ deezerTrack, spotifyTrack });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue track for download
|
||||||
|
await addToQueue({
|
||||||
|
source: 'deezer',
|
||||||
|
type: 'track',
|
||||||
|
title: deezerTrack.title,
|
||||||
|
artist: deezerTrack.artist,
|
||||||
|
totalTracks: 1,
|
||||||
|
downloadObject: deezerTrack
|
||||||
|
});
|
||||||
|
|
||||||
|
queuedCount++;
|
||||||
|
successfulTracks.push({ deezerTrack, spotifyTrack });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[SpotifyPlaylistDownloader] Queued: ${deezerTrack.title} (matched via ${conversionResult.matchMethod})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error);
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[SpotifyPlaylistDownloader] Queued ${queuedCount} tracks, skipped ${skippedCount}, failed ${failedCount}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show queue status message
|
||||||
|
if (queuedCount > 0) {
|
||||||
|
const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`];
|
||||||
|
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
|
||||||
|
if (failedCount > 0) parts.push(`${failedCount} not found`);
|
||||||
|
setInfo(parts.join(', '));
|
||||||
|
} else if (skippedCount > 0) {
|
||||||
|
setWarning(`All ${skippedCount} tracks already exist`);
|
||||||
|
} else if (failedCount > 0) {
|
||||||
|
setWarning(`Could not find ${failedCount} tracks on Deezer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate m3u8 file using Deezer track paths
|
||||||
|
const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack, spotifyTrack }) => {
|
||||||
|
// Generate expected path for this Deezer track
|
||||||
|
const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false);
|
||||||
|
const absolutePath = `${paths.filepath}/${paths.filename}`;
|
||||||
|
|
||||||
|
// Convert to relative path from playlists folder
|
||||||
|
const relativePath = makeRelativePath(absolutePath, 'Music');
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: deezerTrack.duration,
|
||||||
|
artist: deezerTrack.artist,
|
||||||
|
title: deezerTrack.title,
|
||||||
|
path: relativePath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write m3u8 file
|
||||||
|
const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder);
|
||||||
|
|
||||||
|
console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
||||||
|
|
||||||
|
// Show success message for playlist creation
|
||||||
|
setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
m3u8Path,
|
||||||
|
stats: {
|
||||||
|
total: spotifyTracks.length,
|
||||||
|
queued: queuedCount,
|
||||||
|
skipped: skippedCount,
|
||||||
|
failed: failedCount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/lib/stores/deviceSync.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Device sync settings interface
|
||||||
|
export type OverwriteMode = 'skip' | 'different' | 'always';
|
||||||
|
|
||||||
|
export interface DeviceSyncSettings {
|
||||||
|
musicPath: string | null;
|
||||||
|
playlistsPath: string | null;
|
||||||
|
overwriteMode: OverwriteMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the store with device-sync.json
|
||||||
|
const store = new LazyStore('device-sync.json');
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
const defaultSettings: DeviceSyncSettings = {
|
||||||
|
musicPath: null,
|
||||||
|
playlistsPath: null,
|
||||||
|
overwriteMode: 'different'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a writable store for reactive UI updates
|
||||||
|
export const deviceSyncSettings: Writable<DeviceSyncSettings> = writable(defaultSettings);
|
||||||
|
|
||||||
|
// Load settings from store
|
||||||
|
export async function loadDeviceSyncSettings(): Promise<void> {
|
||||||
|
const musicPath = await store.get<string>('musicPath');
|
||||||
|
const playlistsPath = await store.get<string>('playlistsPath');
|
||||||
|
const overwriteMode = await store.get<OverwriteMode>('overwriteMode');
|
||||||
|
|
||||||
|
deviceSyncSettings.set({
|
||||||
|
musicPath: musicPath ?? null,
|
||||||
|
playlistsPath: playlistsPath ?? null,
|
||||||
|
overwriteMode: overwriteMode ?? 'different'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save device music path setting
|
||||||
|
export async function setMusicPath(path: string | null): Promise<void> {
|
||||||
|
if (path) {
|
||||||
|
await store.set('musicPath', path);
|
||||||
|
} else {
|
||||||
|
await store.delete('musicPath');
|
||||||
|
}
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deviceSyncSettings.update(s => ({
|
||||||
|
...s,
|
||||||
|
musicPath: path
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save device playlists path setting
|
||||||
|
export async function setPlaylistsPath(path: string | null): Promise<void> {
|
||||||
|
if (path) {
|
||||||
|
await store.set('playlistsPath', path);
|
||||||
|
} else {
|
||||||
|
await store.delete('playlistsPath');
|
||||||
|
}
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deviceSyncSettings.update(s => ({
|
||||||
|
...s,
|
||||||
|
playlistsPath: path
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save overwrite mode setting
|
||||||
|
export async function setOverwriteMode(mode: OverwriteMode): Promise<void> {
|
||||||
|
await store.set('overwriteMode', mode);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deviceSyncSettings.update(s => ({
|
||||||
|
...s,
|
||||||
|
overwriteMode: mode
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize settings on import
|
||||||
|
loadDeviceSyncSettings();
|
||||||
@@ -50,11 +50,34 @@ export async function loadDownloadQueue(): Promise<void> {
|
|||||||
const queue = await store.get<Record<string, QueueItem>>('queue');
|
const queue = await store.get<Record<string, QueueItem>>('queue');
|
||||||
const currentJob = await store.get<string>('currentJob');
|
const currentJob = await store.get<string>('currentJob');
|
||||||
|
|
||||||
downloadQueue.set({
|
// Reset any items stuck in 'downloading' state from previous session
|
||||||
|
const cleanedQueue = { ...(queue ?? {}) };
|
||||||
|
let resetCount = 0;
|
||||||
|
for (const id in cleanedQueue) {
|
||||||
|
const item = cleanedQueue[id];
|
||||||
|
if (item && item.status === 'downloading') {
|
||||||
|
cleanedQueue[id] = {
|
||||||
|
...item,
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
currentTrack: undefined
|
||||||
|
};
|
||||||
|
resetCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetCount > 0) {
|
||||||
|
console.log(`[DownloadQueue] Reset ${resetCount} interrupted download(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = {
|
||||||
queueOrder: queueOrder ?? [],
|
queueOrder: queueOrder ?? [],
|
||||||
queue: queue ?? {},
|
queue: cleanedQueue,
|
||||||
currentJob: currentJob ?? null
|
currentJob: null // Always clear currentJob on load
|
||||||
});
|
};
|
||||||
|
|
||||||
|
downloadQueue.set(newState);
|
||||||
|
await saveQueue(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save queue to disk
|
// Save queue to disk
|
||||||
|
|||||||
223
src/lib/stores/playback.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { writable, get, type Writable } from 'svelte/store';
|
||||||
|
import type { Track } from '$lib/types/track';
|
||||||
|
|
||||||
|
export interface PlaybackState {
|
||||||
|
currentTrack: Track | null;
|
||||||
|
queue: Track[];
|
||||||
|
queueIndex: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
volume: number; // 0-1
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
lrcPath: string | null; // Path to LRC file if available
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: PlaybackState = {
|
||||||
|
currentTrack: null,
|
||||||
|
queue: [],
|
||||||
|
queueIndex: -1,
|
||||||
|
isPlaying: false,
|
||||||
|
volume: 1,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
lrcPath: null
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaybackStore extends Writable<PlaybackState> {
|
||||||
|
playTrack(track: Track): void;
|
||||||
|
playQueue(tracks: Track[], startIndex?: number): void;
|
||||||
|
addToQueue(tracks: Track[]): void;
|
||||||
|
playNext(tracks: Track[]): void;
|
||||||
|
removeFromQueue(index: number): void;
|
||||||
|
play(): void;
|
||||||
|
pause(): void;
|
||||||
|
togglePlayPause(): void;
|
||||||
|
stop(): void;
|
||||||
|
next(): void;
|
||||||
|
previous(): void;
|
||||||
|
setCurrentTime(time: number): void;
|
||||||
|
setDuration(duration: number): void;
|
||||||
|
setVolume(volume: number): void;
|
||||||
|
setLrcPath(path: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlaybackStore(): PlaybackStore {
|
||||||
|
const { subscribe, set, update } = writable<PlaybackState>(initialState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
set,
|
||||||
|
update,
|
||||||
|
|
||||||
|
// Queue management
|
||||||
|
playTrack(track: Track) {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
currentTrack: track,
|
||||||
|
queue: [track],
|
||||||
|
queueIndex: 0,
|
||||||
|
isPlaying: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
playQueue(tracks: Track[], startIndex = 0) {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
queue: tracks,
|
||||||
|
queueIndex: startIndex,
|
||||||
|
currentTrack: tracks[startIndex],
|
||||||
|
isPlaying: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
addToQueue(tracks: Track[]) {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
queue: [...state.queue, ...tracks]
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
playNext(tracks: Track[]) {
|
||||||
|
update(state => {
|
||||||
|
// Insert after current track
|
||||||
|
if (state.queueIndex >= 0) {
|
||||||
|
const newQueue = [...state.queue];
|
||||||
|
newQueue.splice(state.queueIndex + 1, 0, ...tracks);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queue: newQueue
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queue: [...tracks],
|
||||||
|
queueIndex: 0,
|
||||||
|
currentTrack: tracks[0],
|
||||||
|
isPlaying: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromQueue(index: number) {
|
||||||
|
update(state => {
|
||||||
|
const newQueue = [...state.queue];
|
||||||
|
newQueue.splice(index, 1);
|
||||||
|
|
||||||
|
let newQueueIndex = state.queueIndex;
|
||||||
|
if (index < state.queueIndex) {
|
||||||
|
newQueueIndex--;
|
||||||
|
} else if (index === state.queueIndex) {
|
||||||
|
// Removed current track
|
||||||
|
if (newQueue.length === 0) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isPlaying: false,
|
||||||
|
currentTrack: null,
|
||||||
|
queue: [],
|
||||||
|
queueIndex: -1,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queue: newQueue,
|
||||||
|
queueIndex: newQueueIndex
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Playback control
|
||||||
|
play() {
|
||||||
|
update(state => {
|
||||||
|
if (state.currentTrack) {
|
||||||
|
return { ...state, isPlaying: true };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
update(state => ({ ...state, isPlaying: false }));
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePlayPause() {
|
||||||
|
update(state => ({ ...state, isPlaying: !state.isPlaying }));
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
set({
|
||||||
|
...initialState,
|
||||||
|
volume: get({ subscribe }).volume // Preserve volume
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
next() {
|
||||||
|
update(state => {
|
||||||
|
if (state.queueIndex < state.queue.length - 1) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queueIndex: state.queueIndex + 1,
|
||||||
|
currentTrack: state.queue[state.queueIndex + 1],
|
||||||
|
isPlaying: true,
|
||||||
|
currentTime: 0
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// End of queue - stop
|
||||||
|
return {
|
||||||
|
...initialState,
|
||||||
|
volume: state.volume // Preserve volume
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
previous() {
|
||||||
|
update(state => {
|
||||||
|
// If more than 3 seconds into track, restart current track
|
||||||
|
if (state.currentTime > 3) {
|
||||||
|
return { ...state, currentTime: 0 };
|
||||||
|
} else if (state.queueIndex > 0) {
|
||||||
|
// Go to previous track
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queueIndex: state.queueIndex - 1,
|
||||||
|
currentTrack: state.queue[state.queueIndex - 1],
|
||||||
|
isPlaying: true,
|
||||||
|
currentTime: 0
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// At beginning of queue, restart current track
|
||||||
|
return { ...state, currentTime: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Time and volume
|
||||||
|
setCurrentTime(time: number) {
|
||||||
|
update(state => ({ ...state, currentTime: time }));
|
||||||
|
},
|
||||||
|
|
||||||
|
setDuration(duration: number) {
|
||||||
|
update(state => ({ ...state, duration }));
|
||||||
|
},
|
||||||
|
|
||||||
|
setVolume(volume: number) {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
volume: Math.max(0, Math.min(1, volume))
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lyrics
|
||||||
|
setLrcPath(path: string | null) {
|
||||||
|
update(state => ({ ...state, lrcPath: path }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const playback = createPlaybackStore();
|
||||||
148
src/lib/stores/spotify.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Spotify User interface
|
||||||
|
export interface SpotifyUser {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
email?: string;
|
||||||
|
country?: string;
|
||||||
|
product?: string; // premium, free, etc.
|
||||||
|
images?: Array<{ url: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spotify auth state
|
||||||
|
export interface SpotifyAuthState {
|
||||||
|
// Developer credentials
|
||||||
|
clientId: string | null;
|
||||||
|
clientSecret: string | null;
|
||||||
|
// OAuth tokens
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
expiresAt: number | null; // Unix timestamp in milliseconds
|
||||||
|
// User data
|
||||||
|
user: SpotifyUser | null;
|
||||||
|
loggedIn: boolean;
|
||||||
|
cacheTimestamp: number | null; // Unix timestamp in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the store with spotify.json
|
||||||
|
const store = new LazyStore('spotify.json');
|
||||||
|
|
||||||
|
// Default state
|
||||||
|
const defaultState: SpotifyAuthState = {
|
||||||
|
clientId: null,
|
||||||
|
clientSecret: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
loggedIn: false,
|
||||||
|
cacheTimestamp: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a writable store for reactive UI updates
|
||||||
|
export const spotifyAuth: Writable<SpotifyAuthState> = writable(defaultState);
|
||||||
|
|
||||||
|
// Load Spotify auth state from store
|
||||||
|
export async function loadSpotifyAuth(): Promise<void> {
|
||||||
|
const clientId = await store.get<string>('clientId');
|
||||||
|
const clientSecret = await store.get<string>('clientSecret');
|
||||||
|
const accessToken = await store.get<string>('accessToken');
|
||||||
|
const refreshToken = await store.get<string>('refreshToken');
|
||||||
|
const expiresAt = await store.get<number>('expiresAt');
|
||||||
|
const user = await store.get<SpotifyUser>('user');
|
||||||
|
const cacheTimestamp = await store.get<number>('cacheTimestamp');
|
||||||
|
|
||||||
|
spotifyAuth.set({
|
||||||
|
clientId: clientId ?? null,
|
||||||
|
clientSecret: clientSecret ?? null,
|
||||||
|
accessToken: accessToken ?? null,
|
||||||
|
refreshToken: refreshToken ?? null,
|
||||||
|
expiresAt: expiresAt ?? null,
|
||||||
|
user: user ?? null,
|
||||||
|
loggedIn: !!(accessToken && user),
|
||||||
|
cacheTimestamp: cacheTimestamp ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save client credentials (developer app credentials)
|
||||||
|
export async function saveClientCredentials(clientId: string, clientSecret: string): Promise<void> {
|
||||||
|
await store.set('clientId', clientId);
|
||||||
|
await store.set('clientSecret', clientSecret);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
clientId,
|
||||||
|
clientSecret
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save OAuth tokens
|
||||||
|
export async function saveTokens(accessToken: string, refreshToken: string, expiresIn: number): Promise<void> {
|
||||||
|
const expiresAt = Date.now() + (expiresIn * 1000);
|
||||||
|
|
||||||
|
await store.set('accessToken', accessToken);
|
||||||
|
await store.set('refreshToken', refreshToken);
|
||||||
|
await store.set('expiresAt', expiresAt);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresAt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user data
|
||||||
|
export async function saveUser(user: SpotifyUser): Promise<void> {
|
||||||
|
await store.set('user', user);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
user,
|
||||||
|
loggedIn: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear auth (logout)
|
||||||
|
export async function clearSpotifyAuth(): Promise<void> {
|
||||||
|
await store.delete('accessToken');
|
||||||
|
await store.delete('refreshToken');
|
||||||
|
await store.delete('expiresAt');
|
||||||
|
await store.delete('user');
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
loggedIn: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired or about to expire (within 5 minutes)
|
||||||
|
export function isTokenExpired(expiresAt: number | null): boolean {
|
||||||
|
if (!expiresAt) return true;
|
||||||
|
const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
|
return Date.now() >= (expiresAt - bufferTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save cache timestamp
|
||||||
|
export async function saveCacheTimestamp(timestamp: number): Promise<void> {
|
||||||
|
await store.set('cacheTimestamp', timestamp);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
cacheTimestamp: timestamp
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on module load
|
||||||
|
loadSpotifyAuth();
|
||||||
203
src/lib/stores/status.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Status notification service
|
||||||
|
* Provides a reactive status bar that shows download progress, notifications, and app state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived, get } from 'svelte/store';
|
||||||
|
import { downloadQueue } from './downloadQueue';
|
||||||
|
|
||||||
|
export type StatusLevel = 'idle' | 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface StatusMessage {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
level: StatusLevel;
|
||||||
|
timestamp: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusState {
|
||||||
|
messages: StatusMessage[];
|
||||||
|
nextId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default expiration times (in ms)
|
||||||
|
const EXPIRATION_TIMES: Record<StatusLevel, number> = {
|
||||||
|
idle: 0,
|
||||||
|
info: 3000,
|
||||||
|
success: 4000,
|
||||||
|
warning: 6000,
|
||||||
|
error: 8000
|
||||||
|
};
|
||||||
|
|
||||||
|
// Priority order (higher = more important)
|
||||||
|
const PRIORITY: Record<StatusLevel, number> = {
|
||||||
|
error: 4,
|
||||||
|
warning: 3,
|
||||||
|
success: 2,
|
||||||
|
info: 1,
|
||||||
|
idle: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Internal store
|
||||||
|
const statusState = writable<StatusState>({
|
||||||
|
messages: [],
|
||||||
|
nextId: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a new status message
|
||||||
|
*/
|
||||||
|
export function pushStatus(
|
||||||
|
message: string,
|
||||||
|
level: StatusLevel = 'info',
|
||||||
|
duration?: number
|
||||||
|
): number {
|
||||||
|
const state = get(statusState);
|
||||||
|
const id = state.nextId;
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const expiresAt = timestamp + (duration ?? EXPIRATION_TIMES[level]);
|
||||||
|
|
||||||
|
const newMessage: StatusMessage = {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
timestamp,
|
||||||
|
expiresAt
|
||||||
|
};
|
||||||
|
|
||||||
|
statusState.update(s => ({
|
||||||
|
messages: [...s.messages, newMessage],
|
||||||
|
nextId: s.nextId + 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Auto-remove after expiration
|
||||||
|
if (duration !== 0 && (duration ?? EXPIRATION_TIMES[level]) > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
removeStatus(id);
|
||||||
|
}, duration ?? EXPIRATION_TIMES[level]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a status message by ID
|
||||||
|
*/
|
||||||
|
export function removeStatus(id: number): void {
|
||||||
|
statusState.update(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.filter(m => m.id !== id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all messages of a specific level (or all if no level specified)
|
||||||
|
*/
|
||||||
|
export function clearStatus(level?: StatusLevel): void {
|
||||||
|
statusState.update(s => ({
|
||||||
|
...s,
|
||||||
|
messages: level ? s.messages.filter(m => m.level !== level) : []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience methods
|
||||||
|
*/
|
||||||
|
export function setInfo(message: string, duration?: number): number {
|
||||||
|
return pushStatus(message, 'info', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSuccess(message: string, duration?: number): number {
|
||||||
|
return pushStatus(message, 'success', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWarning(message: string, duration?: number): number {
|
||||||
|
return pushStatus(message, 'warning', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setError(message: string, duration?: number): number {
|
||||||
|
return pushStatus(message, 'error', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive download status from queue state
|
||||||
|
*/
|
||||||
|
function deriveDownloadStatus(queueState: any): string | null {
|
||||||
|
const activeDownloads = queueState.queueOrder.filter((id: string) => {
|
||||||
|
const item = queueState.queue[id];
|
||||||
|
return item && (item.status === 'queued' || item.status === 'downloading');
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentItem = queueState.currentJob
|
||||||
|
? queueState.queue[queueState.currentJob]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (currentItem && currentItem.status === 'downloading') {
|
||||||
|
if (currentItem.type === 'track') {
|
||||||
|
return `Downloading: ${currentItem.title}`;
|
||||||
|
} else {
|
||||||
|
const total = currentItem.totalTracks;
|
||||||
|
const completed = currentItem.completedTracks;
|
||||||
|
const failed = currentItem.failedTracks;
|
||||||
|
const remaining = total - completed - failed;
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
return `Downloading ${currentItem.title} (${completed}/${total} tracks)`;
|
||||||
|
} else {
|
||||||
|
return `Finishing ${currentItem.title}...`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDownloads.length > 0) {
|
||||||
|
return `${activeDownloads.length} download${activeDownloads.length !== 1 ? 's' : ''} queued`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main status store - shows the highest priority active message or derived download status
|
||||||
|
*/
|
||||||
|
export const status = derived(
|
||||||
|
[statusState, downloadQueue],
|
||||||
|
([$statusState, $downloadQueue]) => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Filter out expired messages
|
||||||
|
const activeMessages = $statusState.messages.filter(m => m.expiresAt > now);
|
||||||
|
|
||||||
|
// Get highest priority message
|
||||||
|
if (activeMessages.length > 0) {
|
||||||
|
const sorted = activeMessages.sort((a, b) => {
|
||||||
|
// Sort by priority first
|
||||||
|
const priorityDiff = PRIORITY[b.level] - PRIORITY[a.level];
|
||||||
|
if (priorityDiff !== 0) return priorityDiff;
|
||||||
|
|
||||||
|
// Then by timestamp (newer first)
|
||||||
|
return b.timestamp - a.timestamp;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted[0].message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No explicit messages - check download queue
|
||||||
|
const downloadStatus = deriveDownloadStatus($downloadQueue);
|
||||||
|
if (downloadStatus) {
|
||||||
|
return downloadStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to idle
|
||||||
|
return 'Idle';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up expired messages periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
statusState.update(s => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.filter(m => m.expiresAt > now)
|
||||||
|
}));
|
||||||
|
}, 1000);
|
||||||
82
src/lib/utils/emoji.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Emoji encoding/decoding utilities for filesystem-safe names
|
||||||
|
* Converts emojis to [U+XXXXX] format for safe storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a character is an emoji
|
||||||
|
* Emojis are in Unicode ranges:
|
||||||
|
* - Basic Emoticons: U+1F600 - U+1F64F
|
||||||
|
* - Dingbats: U+2700 - U+27BF
|
||||||
|
* - Miscellaneous Symbols: U+2600 - U+26FF
|
||||||
|
* - Transport and Map: U+1F680 - U+1F6FF
|
||||||
|
* - Supplemental Symbols: U+1F900 - U+1F9FF
|
||||||
|
* - Flags: U+1F1E6 - U+1F1FF
|
||||||
|
* - And many more...
|
||||||
|
*/
|
||||||
|
function isEmoji(codePoint: number): boolean {
|
||||||
|
return (
|
||||||
|
(codePoint >= 0x1F600 && codePoint <= 0x1F64F) || // Emoticons
|
||||||
|
(codePoint >= 0x1F300 && codePoint <= 0x1F5FF) || // Misc Symbols and Pictographs
|
||||||
|
(codePoint >= 0x1F680 && codePoint <= 0x1F6FF) || // Transport and Map
|
||||||
|
(codePoint >= 0x1F900 && codePoint <= 0x1F9FF) || // Supplemental Symbols
|
||||||
|
(codePoint >= 0x1F1E6 && codePoint <= 0x1F1FF) || // Flags
|
||||||
|
(codePoint >= 0x2600 && codePoint <= 0x26FF) || // Misc symbols
|
||||||
|
(codePoint >= 0x2700 && codePoint <= 0x27BF) || // Dingbats
|
||||||
|
(codePoint >= 0xFE00 && codePoint <= 0xFE0F) || // Variation Selectors
|
||||||
|
(codePoint >= 0x1F000 && codePoint <= 0x1F02F) || // Mahjong Tiles
|
||||||
|
(codePoint >= 0x1F0A0 && codePoint <= 0x1F0FF) || // Playing Cards
|
||||||
|
(codePoint >= 0x1FA70 && codePoint <= 0x1FAFF) || // Symbols and Pictographs Extended-A
|
||||||
|
(codePoint >= 0x200D) || // Zero Width Joiner (used in emoji sequences)
|
||||||
|
(codePoint >= 0x231A && codePoint <= 0x231B) || // Watch, Hourglass
|
||||||
|
(codePoint >= 0x23E9 && codePoint <= 0x23F3) || // Media controls
|
||||||
|
(codePoint >= 0x25AA && codePoint <= 0x25AB) || // Geometric shapes
|
||||||
|
(codePoint >= 0x25B6) || // Play button
|
||||||
|
(codePoint >= 0x2934 && codePoint <= 0x2935) || // Arrows
|
||||||
|
(codePoint >= 0x2B05 && codePoint <= 0x2B07) || // Arrows
|
||||||
|
(codePoint >= 0x3030) || // Wavy dash
|
||||||
|
(codePoint >= 0x303D) || // Part alternation mark
|
||||||
|
(codePoint >= 0x3297) || // Japanese symbols
|
||||||
|
(codePoint >= 0x3299) // Japanese symbols
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode emojis in text to [U+XXXXX] format
|
||||||
|
* Example: "hello 👀" → "hello [U+1F440]"
|
||||||
|
*/
|
||||||
|
export function encodeEmojis(text: string): string {
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
// Iterate through Unicode code points (not just chars, to handle surrogate pairs)
|
||||||
|
for (const char of text) {
|
||||||
|
const codePoint = char.codePointAt(0);
|
||||||
|
|
||||||
|
if (codePoint !== undefined && isEmoji(codePoint)) {
|
||||||
|
// Convert to hex string with uppercase
|
||||||
|
const hex = codePoint.toString(16).toUpperCase();
|
||||||
|
result += `[U+${hex}]`;
|
||||||
|
} else {
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode [U+XXXXX] format back to emojis
|
||||||
|
* Example: "hello [U+1F440]" → "hello 👀"
|
||||||
|
*/
|
||||||
|
export function decodeEmojis(text: string): string {
|
||||||
|
// Match [U+XXXXX] patterns (hex can be 4-6 digits for Unicode)
|
||||||
|
return text.replace(/\[U\+([0-9A-Fa-f]+)\]/g, (match, hex) => {
|
||||||
|
try {
|
||||||
|
const codePoint = parseInt(hex, 16);
|
||||||
|
return String.fromCodePoint(codePoint);
|
||||||
|
} catch {
|
||||||
|
// If parsing fails, return the original match
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,15 +3,39 @@
|
|||||||
import TitleBar from "$lib/TitleBar.svelte";
|
import TitleBar from "$lib/TitleBar.svelte";
|
||||||
import MenuBar from "$lib/MenuBar.svelte";
|
import MenuBar from "$lib/MenuBar.svelte";
|
||||||
import ToolBar from "$lib/ToolBar.svelte";
|
import ToolBar from "$lib/ToolBar.svelte";
|
||||||
|
import NowPlayingPanel from "$lib/components/NowPlayingPanel.svelte";
|
||||||
import { settings, loadSettings } from '$lib/stores/settings';
|
import { settings, loadSettings } from '$lib/stores/settings';
|
||||||
import { scanPlaylists, type Playlist } from '$lib/library/scanner';
|
import { scanPlaylists, type Playlist } from '$lib/library/scanner';
|
||||||
import { downloadQueue } from '$lib/stores/downloadQueue';
|
import { downloadQueue } from '$lib/stores/downloadQueue';
|
||||||
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
|
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
|
||||||
|
import { playback } from '$lib/stores/playback';
|
||||||
|
import { status } from '$lib/stores/status';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let playlists = $state<Playlist[]>([]);
|
let playlists = $state<Playlist[]>([]);
|
||||||
let playlistsLoadTimestamp = $state<number>(0);
|
let playlistsLoadTimestamp = $state<number>(0);
|
||||||
|
let showNowPlaying = $state(false);
|
||||||
|
let userHasClosedPanel = $state(false);
|
||||||
|
|
||||||
|
// Auto-show now playing panel when track starts (but only if user hasn't manually closed it)
|
||||||
|
$effect(() => {
|
||||||
|
if ($playback.currentTrack && !showNowPlaying && !userHasClosedPanel) {
|
||||||
|
showNowPlaying = true;
|
||||||
|
}
|
||||||
|
// Reset the closed flag when there's no track
|
||||||
|
if (!$playback.currentTrack) {
|
||||||
|
userHasClosedPanel = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function toggleNowPlaying() {
|
||||||
|
showNowPlaying = !showNowPlaying;
|
||||||
|
// Track when user manually closes the panel
|
||||||
|
if (!showNowPlaying) {
|
||||||
|
userHasClosedPanel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Count active downloads (queued or downloading)
|
// Count active downloads (queued or downloading)
|
||||||
let activeDownloads = $derived(
|
let activeDownloads = $derived(
|
||||||
@@ -85,7 +109,7 @@
|
|||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<MenuBar />
|
<MenuBar />
|
||||||
<ToolBar />
|
<ToolBar onToggleNowPlaying={toggleNowPlaying} />
|
||||||
|
|
||||||
<div class="main-layout">
|
<div class="main-layout">
|
||||||
<aside class="sidebar sunken-panel">
|
<aside class="sidebar sunken-panel">
|
||||||
@@ -108,14 +132,18 @@
|
|||||||
Services
|
Services
|
||||||
</summary>
|
</summary>
|
||||||
<div class="nav-submenu">
|
<div class="nav-submenu">
|
||||||
<!-- <a href="/services/spotify" class="nav-item nav-subitem">
|
<a href="/services/spotify" class="nav-item nav-subitem">
|
||||||
<img src="/icons/spotify.png" alt="" class="nav-icon" />
|
<img src="/icons/spotify.png" alt="" class="nav-icon" />
|
||||||
Spotify
|
Spotify
|
||||||
</a> -->
|
</a>
|
||||||
<a href="/services/deezer" class="nav-item nav-subitem">
|
<a href="/services/deezer" class="nav-item nav-subitem">
|
||||||
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
||||||
Deezer
|
Deezer
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/services/lrclib" class="nav-item nav-subitem">
|
||||||
|
<img src="/icons/lrclib-logo.svg" alt="" class="nav-icon" />
|
||||||
|
LRCLIB
|
||||||
|
</a>
|
||||||
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
|
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
|
||||||
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
||||||
Soulseek
|
Soulseek
|
||||||
@@ -149,12 +177,20 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="content-area sunken-panel">
|
<div class="right-column">
|
||||||
{@render children?.()}
|
<main class="content-area sunken-panel">
|
||||||
</main>
|
{@render children?.()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if showNowPlaying}
|
||||||
|
<div class="banner-panel sunken-panel">
|
||||||
|
<NowPlayingPanel />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-text">Ready</div>
|
<div class="status-text">{$status}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -230,15 +266,32 @@
|
|||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-area {
|
.right-column {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: "Pixelated MS Sans Serif", Arial;
|
font-family: "Pixelated MS Sans Serif", Arial;
|
||||||
background: #121212;
|
background: #121212;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banner-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 120px;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: "Pixelated MS Sans Serif", Arial;
|
||||||
|
background: #121212;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
padding: 6px 8px 10px 10px;
|
padding: 6px 8px 10px 10px;
|
||||||
font-family: "Pixelated MS Sans Serif", Arial;
|
font-family: "Pixelated MS Sans Serif", Arial;
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
{selectedTrackIndex}
|
{selectedTrackIndex}
|
||||||
onTrackClick={handleTrackClick}
|
onTrackClick={handleTrackClick}
|
||||||
showAlbumColumn={false}
|
showAlbumColumn={false}
|
||||||
|
decorationLabel="ALBUM OVERVIEW"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,24 +42,39 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="downloads-page">
|
<div class="downloads-wrapper">
|
||||||
<div class="header">
|
<h2 style="padding: 8px">Downloads</h2>
|
||||||
<h2>Downloads</h2>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
|
|
||||||
Clear Completed
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if queueItems.length === 0}
|
<section class="downloads-content">
|
||||||
<div class="empty-state">
|
<!--
|
||||||
<p>No downloads in queue</p>
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
</div>
|
-->
|
||||||
{:else}
|
<menu role="tablist">
|
||||||
<div class="sunken-panel" style="overflow: auto; flex: 1;">
|
<li role="tab" aria-selected={true}>
|
||||||
<table class="interactive">
|
<button>Queue</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
<div class="tab-header">
|
||||||
|
<h4>{queueItems.length} item{queueItems.length !== 1 ? 's' : ''} in queue</h4>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
|
||||||
|
Clear Completed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if queueItems.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No downloads in queue</p>
|
||||||
|
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-title">Title</th>
|
<th class="col-title">Title</th>
|
||||||
@@ -97,28 +112,62 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.downloads-page {
|
.downloads-wrapper {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.4em;
|
}
|
||||||
|
|
||||||
|
.downloads-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content .window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--button-shadow, #808080);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -126,13 +175,21 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.6;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: light-dark(#666, #999);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
@@ -155,6 +212,9 @@
|
|||||||
|
|
||||||
.col-title {
|
.col-title {
|
||||||
width: auto;
|
width: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-artist {
|
.col-artist {
|
||||||
|
|||||||
@@ -124,8 +124,11 @@
|
|||||||
selectedArtistIndex = index;
|
selectedArtistIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAlbumClick(album: Album, index: number) {
|
function handleAlbumClick(index: number) {
|
||||||
selectedAlbumIndex = index;
|
selectedAlbumIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlbumDoubleClick(album: Album) {
|
||||||
const artistEncoded = encodeURIComponent(album.artist);
|
const artistEncoded = encodeURIComponent(album.artist);
|
||||||
const albumEncoded = encodeURIComponent(album.title);
|
const albumEncoded = encodeURIComponent(album.title);
|
||||||
goto(`/albums/${artistEncoded}/${albumEncoded}`);
|
goto(`/albums/${artistEncoded}/${albumEncoded}`);
|
||||||
@@ -238,7 +241,8 @@
|
|||||||
{#each albums as album, i}
|
{#each albums as album, i}
|
||||||
<tr
|
<tr
|
||||||
class:highlighted={selectedAlbumIndex === i}
|
class:highlighted={selectedAlbumIndex === i}
|
||||||
onclick={() => handleAlbumClick(album, i)}
|
onclick={() => handleAlbumClick(i)}
|
||||||
|
ondblclick={() => handleAlbumDoubleClick(album)}
|
||||||
>
|
>
|
||||||
<td class="cover-cell">
|
<td class="cover-cell">
|
||||||
{#if album.coverArtPath}
|
{#if album.coverArtPath}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
{selectedTrackIndex}
|
{selectedTrackIndex}
|
||||||
onTrackClick={handleTrackClick}
|
onTrackClick={handleTrackClick}
|
||||||
showAlbumColumn={true}
|
showAlbumColumn={true}
|
||||||
|
useSequentialNumbers={true}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,24 +98,29 @@
|
|||||||
// If we have cached album picture, use it
|
// If we have cached album picture, use it
|
||||||
if (cachedTracks[0].album_picture) {
|
if (cachedTracks[0].album_picture) {
|
||||||
playlistPicture = cachedTracks[0].album_picture;
|
playlistPicture = cachedTracks[0].album_picture;
|
||||||
} else if ($deezerAuth.arl && cachedTracks[0].track_id) {
|
} else if ($deezerAuth.arl) {
|
||||||
// Fetch album data from API to get cover
|
// Get the track ID - handle both DeezerTrack (has 'id') and DeezerPlaylistTrack (has 'track_id')
|
||||||
try {
|
const trackId = 'track_id' in cachedTracks[0] ? cachedTracks[0].track_id : cachedTracks[0].id;
|
||||||
deezerAPI.setArl($deezerAuth.arl);
|
|
||||||
const trackData = await deezerAPI.getTrackData(cachedTracks[0].track_id);
|
|
||||||
if (trackData && trackData.ALB_PICTURE) {
|
|
||||||
const albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${trackData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`;
|
|
||||||
playlistPicture = albumCoverUrl;
|
|
||||||
|
|
||||||
// Update cache with the album picture
|
if (trackId) {
|
||||||
const database = await import('$lib/library/deezer-database').then(m => m.initDeezerDatabase());
|
// Fetch album data from API to get cover
|
||||||
await database.execute(
|
try {
|
||||||
'UPDATE deezer_playlist_tracks SET album_picture = $1 WHERE track_id = $2',
|
deezerAPI.setArl($deezerAuth.arl);
|
||||||
[albumCoverUrl, cachedTracks[0].track_id]
|
const trackData = await deezerAPI.getTrack(trackId);
|
||||||
);
|
if (trackData && trackData.ALB_PICTURE) {
|
||||||
|
const albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${trackData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`;
|
||||||
|
playlistPicture = albumCoverUrl;
|
||||||
|
|
||||||
|
// Update cache with the album picture
|
||||||
|
const database = await import('$lib/library/deezer-database').then(m => m.initDeezerDatabase());
|
||||||
|
await database.execute(
|
||||||
|
'UPDATE deezer_playlist_tracks SET album_picture = $1 WHERE track_id = $2',
|
||||||
|
[albumCoverUrl, trackId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch album cover:', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch album cover:', err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +239,6 @@
|
|||||||
title: dbTrack.title,
|
title: dbTrack.title,
|
||||||
artist: dbTrack.artist_name,
|
artist: dbTrack.artist_name,
|
||||||
album: dbTrack.album_title || undefined,
|
album: dbTrack.album_title || undefined,
|
||||||
trackNumber: dbTrack.track_number || undefined,
|
|
||||||
duration: dbTrack.duration
|
duration: dbTrack.duration
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
519
src/routes/services/lrclib/+page.svelte
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { setSuccess, setWarning, setError, setInfo, removeStatus } from '$lib/stores/status';
|
||||||
|
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
|
||||||
|
import { scanForTracksWithoutLyrics, loadCachedTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner';
|
||||||
|
import { getLyricsScanTimestamp, upsertTrack } from '$lib/library/database';
|
||||||
|
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
|
||||||
|
|
||||||
|
type ViewMode = 'tracks' | 'info';
|
||||||
|
|
||||||
|
let viewMode = $state<ViewMode>('tracks');
|
||||||
|
let apiAvailable = $state<boolean | null>(null);
|
||||||
|
let checkingApi = $state(false);
|
||||||
|
let scanning = $state(false);
|
||||||
|
let scanProgress = $state<string | null>(null);
|
||||||
|
let tracks = $state<TrackWithoutLyrics[]>([]);
|
||||||
|
let selectedTrackIndex = $state<number | null>(null);
|
||||||
|
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
|
||||||
|
let lastScanned = $state<number | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await checkApi();
|
||||||
|
await loadCachedResults();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCachedResults() {
|
||||||
|
try {
|
||||||
|
tracks = await loadCachedTracksWithoutLyrics();
|
||||||
|
lastScanned = await getLyricsScanTimestamp();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LRCLIB] Error loading cached results:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkApi() {
|
||||||
|
checkingApi = true;
|
||||||
|
apiAvailable = await checkApiStatus();
|
||||||
|
checkingApi = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScan() {
|
||||||
|
if (!$settings.musicFolder || scanning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanning = true;
|
||||||
|
scanProgress = 'Starting scan...';
|
||||||
|
tracks = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const foundTracks = await scanForTracksWithoutLyrics(
|
||||||
|
$settings.musicFolder,
|
||||||
|
(current, total, message) => {
|
||||||
|
scanProgress = message;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
tracks = foundTracks;
|
||||||
|
lastScanned = await getLyricsScanTimestamp();
|
||||||
|
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
setInfo('All tracks have lyrics!');
|
||||||
|
} else {
|
||||||
|
setInfo(`Found ${tracks.length} track${tracks.length !== 1 ? 's' : ''} without lyrics`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError('Error scanning library: ' + (error instanceof Error ? error.message : String(error)));
|
||||||
|
} finally {
|
||||||
|
scanning = false;
|
||||||
|
scanProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLyricsForTrack(index: number) {
|
||||||
|
const track = tracks[index];
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchAndSaveLyrics(track.path, {
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: track.duration
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Update database to mark track as having lyrics
|
||||||
|
await upsertTrack({
|
||||||
|
path: track.path,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: Math.round(track.duration),
|
||||||
|
format: track.format,
|
||||||
|
has_lyrics: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.instrumental) {
|
||||||
|
setInfo(`Track marked as instrumental: ${track.title}`);
|
||||||
|
} else if (result.hasLyrics) {
|
||||||
|
setSuccess(`Lyrics fetched for ${track.title}`);
|
||||||
|
}
|
||||||
|
// Remove from list on success
|
||||||
|
tracks = tracks.filter((_, i) => i !== index);
|
||||||
|
} else {
|
||||||
|
setWarning(`No lyrics found for ${track.title}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(`Failed to fetch lyrics for ${track.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLyricsForAllTracks() {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const totalTracks = tracks.length;
|
||||||
|
|
||||||
|
// Create a single status message that we'll update
|
||||||
|
const statusId = setInfo(`Fetching lyrics... 0/${totalTracks}`, 0);
|
||||||
|
|
||||||
|
// Process tracks one by one, removing from array as we go
|
||||||
|
let processedCount = 0;
|
||||||
|
while (tracks.length > 0) {
|
||||||
|
const track = tracks[0]; // Always process first track
|
||||||
|
processedCount++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchAndSaveLyrics(track.path, {
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: track.duration
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && (result.hasLyrics || result.instrumental)) {
|
||||||
|
// Update database to mark track as having lyrics
|
||||||
|
await upsertTrack({
|
||||||
|
path: track.path,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: Math.round(track.duration),
|
||||||
|
format: track.format,
|
||||||
|
has_lyrics: true
|
||||||
|
});
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
// Remove from UI immediately on success
|
||||||
|
tracks = tracks.slice(1);
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
// Remove from list even if no lyrics found
|
||||||
|
tracks = tracks.slice(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failCount++;
|
||||||
|
// Remove from list on error
|
||||||
|
tracks = tracks.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress message
|
||||||
|
setInfo(`Fetching lyrics... ${processedCount}/${totalTracks}`, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the progress message
|
||||||
|
removeStatus(statusId);
|
||||||
|
|
||||||
|
// Show completion message
|
||||||
|
if (successCount > 0 && failCount > 0) {
|
||||||
|
setSuccess(`Lyrics found for ${successCount} track${successCount !== 1 ? 's' : ''} (${failCount} failed)`);
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
setSuccess(`Lyrics found for ${successCount} track${successCount !== 1 ? 's' : ''}`);
|
||||||
|
} else {
|
||||||
|
setWarning('No lyrics found for any tracks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackClick(index: number) {
|
||||||
|
selectedTrackIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackDoubleClick(index: number) {
|
||||||
|
fetchLyricsForTrack(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContextMenu(e: MouseEvent, index: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
trackIndex: index
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextMenuItems(trackIndex: number): MenuItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Fetch Lyrics',
|
||||||
|
action: () => fetchLyricsForTrack(trackIndex)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="lrclib-wrapper">
|
||||||
|
<h2 style="padding: 8px">LRCLIB</h2>
|
||||||
|
|
||||||
|
<section class="lrclib-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
-->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={viewMode === 'tracks'}>
|
||||||
|
<button onclick={() => viewMode = 'tracks'}>Missing Lyrics</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'info'}>
|
||||||
|
<button onclick={() => viewMode = 'info'}>Info</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
{#if viewMode === 'tracks'}
|
||||||
|
<!-- Tracks View -->
|
||||||
|
<div class="tab-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
|
||||||
|
{#if lastScanned}
|
||||||
|
<span class="last-scanned">Last scanned: {new Date(lastScanned * 1000).toLocaleString()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="actions-row">
|
||||||
|
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
|
||||||
|
{scanning ? 'Scanning...' : 'Scan Library'}
|
||||||
|
</button>
|
||||||
|
{#if tracks.length > 0}
|
||||||
|
<button onclick={fetchLyricsForAllTracks} disabled={scanning}>
|
||||||
|
Fetch All ({tracks.length})
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if scanProgress}
|
||||||
|
<div class="progress-banner">
|
||||||
|
{scanProgress}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !$settings.musicFolder}
|
||||||
|
<div class="help-banner">
|
||||||
|
Please set a music folder in Settings first
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Results Table -->
|
||||||
|
{#if tracks.length > 0}
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Format</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tracks as track, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedTrackIndex === i}
|
||||||
|
onclick={() => handleTrackClick(i)}
|
||||||
|
ondblclick={() => handleTrackDoubleClick(i)}
|
||||||
|
oncontextmenu={(e) => handleContextMenu(e, i)}
|
||||||
|
>
|
||||||
|
<td>{track.title}</td>
|
||||||
|
<td>{track.artist}</td>
|
||||||
|
<td>{track.album}</td>
|
||||||
|
<td class="duration">{formatDuration(track.duration)}</td>
|
||||||
|
<td class="format">{track.format.toUpperCase()}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if !scanning}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No tracks without lyrics found. Click "Scan Library" to check your library.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if viewMode === 'info'}
|
||||||
|
<!-- Info View -->
|
||||||
|
<div class="info-container">
|
||||||
|
<fieldset>
|
||||||
|
<legend>API Status</legend>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Status:</span>
|
||||||
|
{#if checkingApi}
|
||||||
|
<span>Checking...</span>
|
||||||
|
{:else if apiAvailable === true}
|
||||||
|
<span class="status-indicator status-ok">✓ Available</span>
|
||||||
|
{:else if apiAvailable === false}
|
||||||
|
<span class="status-indicator status-error">✗ Unavailable</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status-indicator">Unknown</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<button onclick={checkApi} disabled={checkingApi}>
|
||||||
|
{checkingApi ? 'Checking...' : 'Check API'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>About LRCLIB</legend>
|
||||||
|
<p>LRCLIB is a free, open API for fetching synchronized and plain lyrics for music tracks.</p>
|
||||||
|
<p>For more info, see <a href="https://lrclib.net/" target="_blank" rel="noopener noreferrer">lrclib.net</a></p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if contextMenu}
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
items={getContextMenuItems(contextMenu.trackIndex)}
|
||||||
|
onClose={() => contextMenu = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lrclib-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lrclib-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--button-shadow, #808080);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-scanned {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok {
|
||||||
|
color: #00aa00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-banner {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--button-shadow, #2a2a2a);
|
||||||
|
border-bottom: 1px solid var(--button-shadow, #808080);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-banner {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--button-shadow, #2a2a2a);
|
||||||
|
border-bottom: 1px solid var(--button-shadow, #808080);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808080;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.6;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-container {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-container fieldset {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-container p {
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
944
src/routes/services/spotify/+page.svelte
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth, saveCacheTimestamp } from '$lib/stores/spotify';
|
||||||
|
import { spotifyAPI } from '$lib/services/spotify';
|
||||||
|
import { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||||
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
|
import {
|
||||||
|
getCachedPlaylists,
|
||||||
|
getCachedAlbums,
|
||||||
|
getCachedArtists,
|
||||||
|
getCachedTracks,
|
||||||
|
upsertPlaylists,
|
||||||
|
upsertAlbums,
|
||||||
|
upsertArtists,
|
||||||
|
upsertTracks,
|
||||||
|
type SpotifyPlaylist,
|
||||||
|
type SpotifyAlbum,
|
||||||
|
type SpotifyArtist,
|
||||||
|
type SpotifyTrack
|
||||||
|
} from '$lib/library/spotify-database';
|
||||||
|
|
||||||
|
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
|
||||||
|
const OAUTH_PORT = 8228;
|
||||||
|
const REDIRECT_URI = `http://127.0.0.1:${OAUTH_PORT}/callback`;
|
||||||
|
|
||||||
|
// Login form state
|
||||||
|
let clientIdInput = $state('');
|
||||||
|
let clientSecretInput = $state('');
|
||||||
|
let isAuthenticating = $state(false);
|
||||||
|
let loginError = $state('');
|
||||||
|
let loginSuccess = $state('');
|
||||||
|
|
||||||
|
// OAuth state
|
||||||
|
let isWaitingForCallback = $state(false);
|
||||||
|
let oauthUnlisten: (() => void) | null = $state(null);
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
type ViewMode = 'playlists' | 'tracks' | 'artists' | 'albums' | 'info';
|
||||||
|
let viewMode = $state<ViewMode>('playlists');
|
||||||
|
let playlists = $state<SpotifyPlaylist[]>([]);
|
||||||
|
let albums = $state<SpotifyAlbum[]>([]);
|
||||||
|
let artists = $state<SpotifyArtist[]>([]);
|
||||||
|
let tracks = $state<SpotifyTrack[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let syncing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let selectedIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
// User refresh state
|
||||||
|
let refreshingUser = $state(false);
|
||||||
|
let userRefreshMessage = $state('');
|
||||||
|
|
||||||
|
const CACHE_DURATION = 24 * 60 * 60; // 24 hours in seconds
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadSpotifyAuth();
|
||||||
|
|
||||||
|
// Check if we have client credentials stored
|
||||||
|
if ($spotifyAuth.clientId) {
|
||||||
|
clientIdInput = $spotifyAuth.clientId;
|
||||||
|
}
|
||||||
|
if ($spotifyAuth.clientSecret) {
|
||||||
|
clientSecretInput = $spotifyAuth.clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($spotifyAuth.loggedIn) {
|
||||||
|
await loadFavorites();
|
||||||
|
} else {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFavorites() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
$spotifyAuth.accessToken!,
|
||||||
|
$spotifyAuth.refreshToken!,
|
||||||
|
$spotifyAuth.expiresAt!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if we need to refresh cache
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const cacheAge = $spotifyAuth.cacheTimestamp ? now - $spotifyAuth.cacheTimestamp : Infinity;
|
||||||
|
const needsRefresh = cacheAge > CACHE_DURATION;
|
||||||
|
|
||||||
|
if (needsRefresh) {
|
||||||
|
await refreshFavorites();
|
||||||
|
} else {
|
||||||
|
// Load from cache
|
||||||
|
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
|
||||||
|
getCachedPlaylists(),
|
||||||
|
getCachedAlbums(),
|
||||||
|
getCachedArtists(),
|
||||||
|
getCachedTracks()
|
||||||
|
]);
|
||||||
|
|
||||||
|
playlists = cachedPlaylists;
|
||||||
|
albums = cachedAlbums;
|
||||||
|
artists = cachedArtists;
|
||||||
|
tracks = cachedTracks;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
|
||||||
|
await clearSpotifyAuth();
|
||||||
|
error = 'Your Spotify session has expired. Please log in again.';
|
||||||
|
} else {
|
||||||
|
error = 'Error loading favorites: ' + errorMessage;
|
||||||
|
}
|
||||||
|
viewMode = 'info';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshFavorites() {
|
||||||
|
if (!$spotifyAuth.accessToken || syncing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
$spotifyAuth.accessToken,
|
||||||
|
$spotifyAuth.refreshToken!,
|
||||||
|
$spotifyAuth.expiresAt!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch all favorites from API
|
||||||
|
console.log('[Spotify] Fetching favorites from API...');
|
||||||
|
const [apiPlaylists, apiAlbums, apiArtists, apiTracks] = await Promise.all([
|
||||||
|
spotifyAPI.getAllUserPlaylists(),
|
||||||
|
spotifyAPI.getAllUserAlbums(),
|
||||||
|
spotifyAPI.getAllUserArtists(),
|
||||||
|
spotifyAPI.getAllUserTracks()
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('[Spotify] Fetched from API:', {
|
||||||
|
playlists: apiPlaylists.length,
|
||||||
|
albums: apiAlbums.length,
|
||||||
|
artists: apiArtists.length,
|
||||||
|
tracks: apiTracks.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update database cache
|
||||||
|
console.log('[Spotify] Updating database cache...');
|
||||||
|
await upsertPlaylists(apiPlaylists);
|
||||||
|
await upsertAlbums(apiAlbums);
|
||||||
|
await upsertArtists(apiArtists);
|
||||||
|
await upsertTracks(apiTracks);
|
||||||
|
|
||||||
|
// Update cache timestamp
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await saveCacheTimestamp(now);
|
||||||
|
|
||||||
|
console.log('[Spotify] Reloading from cache...');
|
||||||
|
// Reload from cache
|
||||||
|
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
|
||||||
|
getCachedPlaylists(),
|
||||||
|
getCachedAlbums(),
|
||||||
|
getCachedArtists(),
|
||||||
|
getCachedTracks()
|
||||||
|
]);
|
||||||
|
|
||||||
|
playlists = cachedPlaylists;
|
||||||
|
albums = cachedAlbums;
|
||||||
|
artists = cachedArtists;
|
||||||
|
tracks = cachedTracks;
|
||||||
|
|
||||||
|
console.log('[Spotify] Refresh complete!');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error refreshing favorites:', e);
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
|
||||||
|
await clearSpotifyAuth();
|
||||||
|
error = 'Your Spotify session has expired. Please log in again.';
|
||||||
|
} else {
|
||||||
|
error = 'Error refreshing favorites: ' + errorMessage;
|
||||||
|
}
|
||||||
|
viewMode = 'info';
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthorize() {
|
||||||
|
if (!clientIdInput || !clientSecretInput) {
|
||||||
|
loginError = 'Please enter both Client ID and Client Secret';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientIdInput.trim().length === 0 || clientSecretInput.trim().length === 0) {
|
||||||
|
loginError = 'Client ID and Client Secret cannot be empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticating = true;
|
||||||
|
loginError = '';
|
||||||
|
loginSuccess = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save credentials
|
||||||
|
await saveClientCredentials(clientIdInput.trim(), clientSecretInput.trim());
|
||||||
|
|
||||||
|
// Clean up any existing OAuth listener
|
||||||
|
if (oauthUnlisten) {
|
||||||
|
oauthUnlisten();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up OAuth callback listener and store unlisten function
|
||||||
|
oauthUnlisten = await onUrl((callbackUrl) => {
|
||||||
|
handleOAuthCallback(callbackUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start OAuth server on fixed port with custom styled response
|
||||||
|
const port = await start({
|
||||||
|
ports: [OAUTH_PORT],
|
||||||
|
response: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Spotify Authorization Complete</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
||||||
|
background: #008080;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.window {
|
||||||
|
background: silver;
|
||||||
|
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||||
|
padding: 3px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.title-bar {
|
||||||
|
background: linear-gradient(90deg, navy, #1084d0);
|
||||||
|
padding: 3px 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.window-body {
|
||||||
|
background: silver;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 3px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="window">
|
||||||
|
<div class="title-bar">
|
||||||
|
<span>Spotify Authorization</span>
|
||||||
|
</div>
|
||||||
|
<div class="window-body">
|
||||||
|
<h1>Authorization Complete</h1>
|
||||||
|
<p>You have successfully authorized Shark with your Spotify account.</p>
|
||||||
|
<p>You can close this window and return to the app.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
console.log(`[Spotify] OAuth server started on port ${port}`);
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
const { url, codeVerifier } = await spotifyAPI.getAuthorizationUrl(
|
||||||
|
clientIdInput.trim(),
|
||||||
|
REDIRECT_URI
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store code verifier for callback
|
||||||
|
localStorage.setItem('spotify_code_verifier', codeVerifier);
|
||||||
|
|
||||||
|
isWaitingForCallback = true;
|
||||||
|
|
||||||
|
// Open Spotify authorization in default browser
|
||||||
|
await openUrl(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Spotify] Authorization error:', error);
|
||||||
|
loginError = `Authorization error: ${error instanceof Error ? error.message : JSON.stringify(error)}`;
|
||||||
|
isAuthenticating = false;
|
||||||
|
isWaitingForCallback = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOAuthCallback(callbackUrl: string) {
|
||||||
|
// Immediately remove the listener to prevent duplicate calls from hot reload
|
||||||
|
if (oauthUnlisten) {
|
||||||
|
oauthUnlisten();
|
||||||
|
oauthUnlisten = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse the callback URL
|
||||||
|
const url = new URL(callbackUrl);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Authorization failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('No authorization code received');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve code verifier from localStorage
|
||||||
|
const codeVerifier = localStorage.getItem('spotify_code_verifier');
|
||||||
|
|
||||||
|
if (!codeVerifier) {
|
||||||
|
throw new Error('OAuth state lost. Please try logging in again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokenData = await spotifyAPI.exchangeCodeForToken(
|
||||||
|
code,
|
||||||
|
codeVerifier,
|
||||||
|
$spotifyAuth.clientId!,
|
||||||
|
REDIRECT_URI
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save tokens
|
||||||
|
await saveTokens(tokenData.access_token, tokenData.refresh_token, tokenData.expires_in);
|
||||||
|
|
||||||
|
// Set tokens in API client
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
tokenData.access_token,
|
||||||
|
tokenData.refresh_token,
|
||||||
|
Date.now() + (tokenData.expires_in * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
|
const user = await spotifyAPI.getCurrentUser();
|
||||||
|
await saveUser(user);
|
||||||
|
|
||||||
|
// Load favorites after successful login
|
||||||
|
await loadFavorites();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
localStorage.removeItem('spotify_code_verifier');
|
||||||
|
} catch (error) {
|
||||||
|
loginError = `Authentication error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
localStorage.removeItem('spotify_code_verifier');
|
||||||
|
} finally {
|
||||||
|
isAuthenticating = false;
|
||||||
|
isWaitingForCallback = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await clearSpotifyAuth();
|
||||||
|
clientIdInput = '';
|
||||||
|
clientSecretInput = '';
|
||||||
|
loginSuccess = '';
|
||||||
|
loginError = '';
|
||||||
|
playlists = [];
|
||||||
|
albums = [];
|
||||||
|
artists = [];
|
||||||
|
tracks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshUser() {
|
||||||
|
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshingUser = true;
|
||||||
|
userRefreshMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set credentials in API client
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
$spotifyAuth.accessToken,
|
||||||
|
$spotifyAuth.refreshToken!,
|
||||||
|
$spotifyAuth.expiresAt!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch updated user info
|
||||||
|
const user = await spotifyAPI.getCurrentUser();
|
||||||
|
await saveUser(user);
|
||||||
|
|
||||||
|
userRefreshMessage = 'User info refreshed successfully!';
|
||||||
|
setTimeout(() => {
|
||||||
|
userRefreshMessage = '';
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
userRefreshMessage = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
refreshingUser = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
userRefreshMessage = '';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemClick(index: number) {
|
||||||
|
selectedIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlaylistDoubleClick(playlistId: string) {
|
||||||
|
goto(`/services/spotify/playlists/${playlistId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const mins = Math.floor(ms / 60000);
|
||||||
|
const secs = Math.floor((ms % 60000) / 1000);
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="spotify-wrapper">
|
||||||
|
<h2 style="padding: 8px">Spotify</h2>
|
||||||
|
|
||||||
|
{#if !$spotifyAuth.loggedIn}
|
||||||
|
<!-- Login Form -->
|
||||||
|
<section class="window login-section" style="max-width: 600px; margin: 8px;">
|
||||||
|
<div class="title-bar">
|
||||||
|
<div class="title-bar-text">Login to Spotify</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-body">
|
||||||
|
<p>Enter your Spotify Developer credentials and authorize access:</p>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="client-id-input">Client ID</label>
|
||||||
|
<input
|
||||||
|
id="client-id-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={clientIdInput}
|
||||||
|
placeholder="Your Spotify App Client ID"
|
||||||
|
disabled={isAuthenticating || isWaitingForCallback}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="client-secret-input">Client Secret</label>
|
||||||
|
<input
|
||||||
|
id="client-secret-input"
|
||||||
|
type="password"
|
||||||
|
bind:value={clientSecretInput}
|
||||||
|
placeholder="Your Spotify App Client Secret"
|
||||||
|
disabled={isAuthenticating || isWaitingForCallback}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loginError}
|
||||||
|
<div class="error-message">
|
||||||
|
⚠ {loginError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isWaitingForCallback}
|
||||||
|
<div class="info-message">
|
||||||
|
Waiting for authorization in your browser... Please complete the login process.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button onclick={handleAuthorize} disabled={isAuthenticating || isWaitingForCallback}>
|
||||||
|
{isAuthenticating ? 'Authorizing...' : 'Authorize with Spotify'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 8px; font-size: 11px; opacity: 0.7;">
|
||||||
|
This will open Spotify's login page in your default browser.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details class="instructions">
|
||||||
|
<summary>How to get your Spotify Developer credentials</summary>
|
||||||
|
<div class="instructions-content">
|
||||||
|
<ol>
|
||||||
|
<li>Go to <strong>developer.spotify.com/dashboard</strong></li>
|
||||||
|
<li>Log in with your Spotify account</li>
|
||||||
|
<li>Click <strong>"Create app"</strong></li>
|
||||||
|
<li>Fill in the app details:
|
||||||
|
<ul>
|
||||||
|
<li>App name: (any name you want, e.g., "Shark Music Player")</li>
|
||||||
|
<li>App description: (any description)</li>
|
||||||
|
<li>Redirect URI: <code>http://127.0.0.1:8228/callback</code></li>
|
||||||
|
<li>Check the Web API box</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>"Save"</strong></li>
|
||||||
|
<li>Click <strong>"Settings"</strong> on your new app</li>
|
||||||
|
<li>Copy the <strong>Client ID</strong> (visible by default)</li>
|
||||||
|
<li>Click <strong>"View client secret"</strong> and copy the <strong>Client Secret</strong></li>
|
||||||
|
<li>Paste both values into the fields above</li>
|
||||||
|
</ol>
|
||||||
|
<p><strong>Note:</strong> The Client ID and Client Secret are used to authenticate your app with Spotify. Keep the Client Secret private and never share it publicly.</p>
|
||||||
|
<p><strong>Important:</strong> The Redirect URI must be exactly <code>http://127.0.0.1:8228/callback</code>. Port 8228 must be available when authorizing. If you get a port error, close any application using port 8228.</p>
|
||||||
|
<p><strong>Scopes used:</strong> This app requests access to your profile, email, saved library (tracks, albums), playlists (including private and collaborative), and followed artists.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if loading}
|
||||||
|
<p style="padding: 8px;">Loading favorites...</p>
|
||||||
|
{:else}
|
||||||
|
<section class="favorites-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
-->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={viewMode === 'playlists'}>
|
||||||
|
<button onclick={() => viewMode = 'playlists'}>Playlists</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'tracks'}>
|
||||||
|
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'artists'}>
|
||||||
|
<button onclick={() => viewMode = 'artists'}>Artists</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'albums'}>
|
||||||
|
<button onclick={() => viewMode = 'albums'}>Albums</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'info'}>
|
||||||
|
<button onclick={() => viewMode = 'info'}>Info</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
{#if syncing}
|
||||||
|
<div class="sync-status">
|
||||||
|
<p>Refreshing favorites from Spotify...</p>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'playlists'}
|
||||||
|
<!-- Playlists View -->
|
||||||
|
<div class="tab-header">
|
||||||
|
<h4>Favorite Playlists</h4>
|
||||||
|
<button onclick={refreshFavorites} disabled={syncing}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Playlist</th>
|
||||||
|
<th>Tracks</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Virtual Spotify Likes Playlist (only show if we have favorite tracks) -->
|
||||||
|
{#if tracks.length > 0}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedIndex === -1}
|
||||||
|
class="favorite-tracks-row"
|
||||||
|
onclick={() => handleItemClick(-1)}
|
||||||
|
ondblclick={() => handlePlaylistDoubleClick('spotify-likes')}
|
||||||
|
>
|
||||||
|
<td>Spotify Likes</td>
|
||||||
|
<td>{tracks.length}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- User Playlists -->
|
||||||
|
{#each playlists as playlist, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedIndex === i}
|
||||||
|
onclick={() => handleItemClick(i)}
|
||||||
|
ondblclick={() => handlePlaylistDoubleClick(playlist.id)}
|
||||||
|
>
|
||||||
|
<td>{playlist.name}</td>
|
||||||
|
<td>{playlist.track_count}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'tracks'}
|
||||||
|
<!-- Tracks View -->
|
||||||
|
<div class="tab-header">
|
||||||
|
<h4>Favorite Tracks</h4>
|
||||||
|
<button onclick={refreshFavorites} disabled={syncing}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tracks as track, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedIndex === i}
|
||||||
|
onclick={() => handleItemClick(i)}
|
||||||
|
>
|
||||||
|
<td>{track.name}</td>
|
||||||
|
<td>{track.artist_name}</td>
|
||||||
|
<td>{track.album_name}</td>
|
||||||
|
<td class="duration">{formatDuration(track.duration_ms)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'artists'}
|
||||||
|
<!-- Artists View -->
|
||||||
|
<div class="tab-header">
|
||||||
|
<h4>Followed Artists</h4>
|
||||||
|
<button onclick={refreshFavorites} disabled={syncing}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Followers</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each artists as artist, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedIndex === i}
|
||||||
|
onclick={() => handleItemClick(i)}
|
||||||
|
>
|
||||||
|
<td>{artist.name}</td>
|
||||||
|
<td>{artist.followers.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'albums'}
|
||||||
|
<!-- Albums View -->
|
||||||
|
<div class="tab-header">
|
||||||
|
<h4>Saved Albums</h4>
|
||||||
|
<button onclick={refreshFavorites} disabled={syncing}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Year</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each albums as album, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedIndex === i}
|
||||||
|
onclick={() => handleItemClick(i)}
|
||||||
|
>
|
||||||
|
<td>{album.name}</td>
|
||||||
|
<td>{album.artist_name}</td>
|
||||||
|
<td>{album.release_date ? new Date(album.release_date).getFullYear() : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'info'}
|
||||||
|
<!-- User Info View -->
|
||||||
|
<div class="user-container">
|
||||||
|
{#if error}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Error</legend>
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>User Information</legend>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Name:</span>
|
||||||
|
<span>{$spotifyAuth.user?.display_name || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Email:</span>
|
||||||
|
<span>{$spotifyAuth.user?.email || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Country:</span>
|
||||||
|
<span>{$spotifyAuth.user?.country || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Subscription:</span>
|
||||||
|
<span>{$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if userRefreshMessage}
|
||||||
|
<div class="message-box">
|
||||||
|
{userRefreshMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<fieldset style="margin-top: 16px;">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
<div class="button-row">
|
||||||
|
<button onclick={handleRefreshUser} disabled={refreshingUser}>
|
||||||
|
{refreshingUser ? 'Refreshing...' : 'Refresh User Info'}
|
||||||
|
</button>
|
||||||
|
<button onclick={refreshFavorites} disabled={syncing}>
|
||||||
|
{syncing ? 'Refreshing...' : 'Refresh Cache'}
|
||||||
|
</button>
|
||||||
|
<button onclick={handleLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spotify-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content .window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--button-shadow, #808080);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box {
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
background-color: var(--button-shadow, #2a2a2a);
|
||||||
|
border: 1px solid var(--button-highlight, #606060);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
padding: 16px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-tracks-row {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row-stacked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #ffcccc;
|
||||||
|
color: #cc0000;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-message {
|
||||||
|
background-color: #cce5ff;
|
||||||
|
color: #004085;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--button-shadow, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content ol {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content ul {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content li {
|
||||||
|
margin: 6px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content code {
|
||||||
|
background-color: var(--button-highlight, #505050);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content p {
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
307
src/routes/services/spotify/playlists/[id]/+page.svelte
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { spotifyAuth } from '$lib/stores/spotify';
|
||||||
|
import { spotifyAPI } from '$lib/services/spotify';
|
||||||
|
import {
|
||||||
|
getCachedPlaylist,
|
||||||
|
getCachedPlaylistTracks,
|
||||||
|
getCachedTracks,
|
||||||
|
upsertPlaylistTracks,
|
||||||
|
type SpotifyPlaylist,
|
||||||
|
type SpotifyPlaylistTrack,
|
||||||
|
type SpotifyTrack
|
||||||
|
} from '$lib/library/spotify-database';
|
||||||
|
import SpotifyCollectionView from '$lib/components/SpotifyCollectionView.svelte';
|
||||||
|
import type { Track, AudioFormat } from '$lib/types/track';
|
||||||
|
import { addSpotifyTrackToQueue } from '$lib/services/spotify/addToQueue';
|
||||||
|
import { downloadSpotifyPlaylist } from '$lib/services/spotify/playlistDownloader';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { setError } from '$lib/stores/status';
|
||||||
|
|
||||||
|
let playlistId = $derived($page.params.id!);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let playlist = $state<SpotifyPlaylist | null>(null);
|
||||||
|
let playlistTracks = $state<SpotifyPlaylistTrack[]>([]);
|
||||||
|
let selectedTrackIndex = $state<number | null>(null);
|
||||||
|
let coverImageUrl = $state<string | undefined>(undefined);
|
||||||
|
let downloadingTrackIds = $state(new Set<string>());
|
||||||
|
|
||||||
|
// Convert Spotify tracks to Track type for CollectionView
|
||||||
|
let tracks = $derived<Track[]>(
|
||||||
|
playlistTracks.map((track, i) => ({
|
||||||
|
path: '',
|
||||||
|
filename: '',
|
||||||
|
format: 'unknown' as AudioFormat,
|
||||||
|
spotifyId: track.track_id, // Store Spotify ID for downloading
|
||||||
|
metadata: {
|
||||||
|
title: track.name || 'Unknown Title',
|
||||||
|
artist: track.artist_name || 'Unknown Artist',
|
||||||
|
album: track.album_name || 'Unknown Album',
|
||||||
|
trackNumber: track.track_number ?? i + 1,
|
||||||
|
duration: Math.floor(track.duration_ms / 1000)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadPlaylist();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPlaylist() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (playlistId === 'spotify-likes') {
|
||||||
|
// Special case: load all favorite tracks as a virtual playlist
|
||||||
|
await loadSpotifyLikes();
|
||||||
|
} else {
|
||||||
|
// Load regular playlist
|
||||||
|
await loadRegularPlaylist();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading playlist:', e);
|
||||||
|
error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSpotifyLikes() {
|
||||||
|
const allTracks = await getCachedTracks();
|
||||||
|
|
||||||
|
// Create virtual playlist object
|
||||||
|
playlist = {
|
||||||
|
id: 'spotify-likes',
|
||||||
|
name: 'Spotify Likes',
|
||||||
|
track_count: allTracks.length,
|
||||||
|
owner_name: $spotifyAuth.user!.display_name,
|
||||||
|
cached_at: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert SpotifyTrack[] to SpotifyPlaylistTrack[]
|
||||||
|
playlistTracks = allTracks.map((track, i) => ({
|
||||||
|
id: i,
|
||||||
|
playlist_id: 'spotify-likes',
|
||||||
|
track_id: track.id,
|
||||||
|
name: track.name,
|
||||||
|
artist_name: track.artist_name,
|
||||||
|
album_name: track.album_name,
|
||||||
|
duration_ms: track.duration_ms,
|
||||||
|
track_number: i + 1,
|
||||||
|
isrc: track.isrc,
|
||||||
|
cached_at: track.cached_at
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Set cover art from first track's album
|
||||||
|
if (allTracks.length > 0) {
|
||||||
|
if (allTracks[0].album_image_url) {
|
||||||
|
coverImageUrl = allTracks[0].album_image_url;
|
||||||
|
} else if ($spotifyAuth.accessToken && $spotifyAuth.clientId && $spotifyAuth.clientSecret && $spotifyAuth.refreshToken) {
|
||||||
|
try {
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
$spotifyAuth.accessToken,
|
||||||
|
$spotifyAuth.refreshToken,
|
||||||
|
$spotifyAuth.expiresAt!
|
||||||
|
);
|
||||||
|
|
||||||
|
const trackData = await spotifyAPI.apiCall<any>(`/tracks/${allTracks[0].id}`);
|
||||||
|
const albumImageUrl = trackData.album?.images?.[0]?.url;
|
||||||
|
|
||||||
|
if (albumImageUrl) {
|
||||||
|
coverImageUrl = albumImageUrl;
|
||||||
|
|
||||||
|
const database = await import('$lib/library/spotify-database').then(m => m.initSpotifyDatabase());
|
||||||
|
await database.execute(
|
||||||
|
'UPDATE spotify_tracks SET album_image_url = $1 WHERE id = $2',
|
||||||
|
[albumImageUrl, allTracks[0].id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch album cover for Spotify Likes:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegularPlaylist() {
|
||||||
|
// Load playlist metadata
|
||||||
|
const cachedPlaylist = await getCachedPlaylist(playlistId);
|
||||||
|
|
||||||
|
if (!cachedPlaylist) {
|
||||||
|
// Playlist not in cache, try to fetch from API
|
||||||
|
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret || !$spotifyAuth.refreshToken) {
|
||||||
|
error = 'Not logged in to Spotify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
$spotifyAuth.accessToken,
|
||||||
|
$spotifyAuth.refreshToken,
|
||||||
|
$spotifyAuth.expiresAt!
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiPlaylist = await spotifyAPI.getPlaylist(playlistId);
|
||||||
|
|
||||||
|
// Create playlist object from API response
|
||||||
|
playlist = {
|
||||||
|
id: apiPlaylist.id,
|
||||||
|
name: apiPlaylist.name,
|
||||||
|
track_count: apiPlaylist.tracks?.total || 0,
|
||||||
|
owner_name: apiPlaylist.owner?.display_name || 'Unknown',
|
||||||
|
image_url: apiPlaylist.images?.[0]?.url,
|
||||||
|
cached_at: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
|
||||||
|
coverImageUrl = playlist.image_url;
|
||||||
|
|
||||||
|
// Fetch and cache tracks
|
||||||
|
const apiTracks = await spotifyAPI.getPlaylistTracks(playlistId);
|
||||||
|
await upsertPlaylistTracks(playlistId, apiTracks);
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Playlist not found and could not be fetched from Spotify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playlist = cachedPlaylist;
|
||||||
|
coverImageUrl = playlist.image_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tracks
|
||||||
|
const cachedTracks = await getCachedPlaylistTracks(playlistId);
|
||||||
|
|
||||||
|
if (cachedTracks.length === 0 && playlist.track_count > 0) {
|
||||||
|
// Tracks not in cache, fetch from API
|
||||||
|
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret || !$spotifyAuth.refreshToken) {
|
||||||
|
error = 'Cannot load tracks: Not logged in to Spotify';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
$spotifyAuth.accessToken,
|
||||||
|
$spotifyAuth.refreshToken,
|
||||||
|
$spotifyAuth.expiresAt!
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiTracks = await spotifyAPI.getPlaylistTracks(playlistId);
|
||||||
|
await upsertPlaylistTracks(playlistId, apiTracks);
|
||||||
|
|
||||||
|
// Reload from cache
|
||||||
|
playlistTracks = await getCachedPlaylistTracks(playlistId);
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Error fetching playlist tracks from Spotify';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playlistTracks = cachedTracks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackClick(index: number) {
|
||||||
|
selectedTrackIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadTrack(index: number) {
|
||||||
|
if (!$deezerAuth.loggedIn) {
|
||||||
|
setError('Deezer login required for downloads');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotifyTrack = playlistTracks[index];
|
||||||
|
if (!spotifyTrack) return;
|
||||||
|
|
||||||
|
// Mark as downloading
|
||||||
|
downloadingTrackIds = new Set(downloadingTrackIds).add(spotifyTrack.track_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addSpotifyTrackToQueue({
|
||||||
|
id: spotifyTrack.track_id,
|
||||||
|
name: spotifyTrack.name,
|
||||||
|
artist_name: spotifyTrack.artist_name,
|
||||||
|
album_name: spotifyTrack.album_name,
|
||||||
|
duration_ms: spotifyTrack.duration_ms,
|
||||||
|
isrc: spotifyTrack.isrc
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading track:', error);
|
||||||
|
} finally {
|
||||||
|
// Remove from downloading set
|
||||||
|
const newSet = new Set(downloadingTrackIds);
|
||||||
|
newSet.delete(spotifyTrack.track_id);
|
||||||
|
downloadingTrackIds = newSet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadPlaylist() {
|
||||||
|
if (!$deezerAuth.loggedIn) {
|
||||||
|
setError('Deezer login required for downloads');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlist || !$settings.musicFolder || !$settings.playlistsFolder) {
|
||||||
|
setError('Please configure music and playlists folders in settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadSpotifyPlaylist(
|
||||||
|
playlist.name,
|
||||||
|
playlistTracks,
|
||||||
|
$settings.playlistsFolder,
|
||||||
|
$settings.musicFolder
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading playlist:', error);
|
||||||
|
setError(
|
||||||
|
'Error downloading playlist: ' + (error instanceof Error ? error.message : String(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="wrapper">
|
||||||
|
<p style="padding: 8px;">Loading playlist...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="window" style="margin: 8px;">
|
||||||
|
<div class="title-bar">
|
||||||
|
<div class="title-bar-text">Error</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-body">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if playlist}
|
||||||
|
<div class="wrapper">
|
||||||
|
<SpotifyCollectionView
|
||||||
|
title={playlist.name}
|
||||||
|
subtitle={playlist.owner_name}
|
||||||
|
metadata="{playlist.track_count} tracks"
|
||||||
|
{coverImageUrl}
|
||||||
|
{tracks}
|
||||||
|
{selectedTrackIndex}
|
||||||
|
{downloadingTrackIds}
|
||||||
|
onTrackClick={handleTrackClick}
|
||||||
|
onDownloadTrack={handleDownloadTrack}
|
||||||
|
onDownloadPlaylist={handleDownloadPlaylist}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
import { clearDeezerCache } from '$lib/library/deezer-database';
|
import { clearDeezerCache } from '$lib/library/deezer-database';
|
||||||
import { open, confirm, message } from '@tauri-apps/plugin-dialog';
|
import { open, confirm, message } from '@tauri-apps/plugin-dialog';
|
||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
|
import { appDataDir } from '@tauri-apps/api/path';
|
||||||
|
import { openPath } from '@tauri-apps/plugin-opener';
|
||||||
|
|
||||||
let currentMusicFolder = $state<string | null>(null);
|
let currentMusicFolder = $state<string | null>(null);
|
||||||
let currentPlaylistsFolder = $state<string | null>(null);
|
let currentPlaylistsFolder = $state<string | null>(null);
|
||||||
@@ -122,34 +124,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAppDataFolder() {
|
||||||
|
try {
|
||||||
|
const dataPath = await appDataDir();
|
||||||
|
console.log('App data path:', dataPath);
|
||||||
|
if (!dataPath) {
|
||||||
|
throw new Error('Could not get app data directory path');
|
||||||
|
}
|
||||||
|
await openPath(dataPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening app data folder:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
await message('Error opening app data folder: ' + errorMessage, { title: 'Error', kind: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style="padding: 8px;">
|
<div class="settings-wrapper">
|
||||||
<h2>Settings</h2>
|
<h2 style="padding: 8px">Settings</h2>
|
||||||
<!--
|
|
||||||
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
|
||||||
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
|
||||||
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
|
|
||||||
The <menu> element IS interactive (contains clickable <button> elements) and the
|
|
||||||
role="tablist" properly describes the semantic purpose to assistive technology.
|
|
||||||
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
|
|
||||||
-->
|
|
||||||
<menu role="tablist">
|
|
||||||
<li role="tab" aria-selected={activeTab === 'library'}>
|
|
||||||
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
|
|
||||||
</li>
|
|
||||||
<li role="tab" aria-selected={activeTab === 'deezer'}>
|
|
||||||
<a href="#deezer" onclick={(e) => { e.preventDefault(); activeTab = 'deezer'; }}>Deezer</a>
|
|
||||||
</li>
|
|
||||||
<li role="tab" aria-selected={activeTab === 'advanced'}>
|
|
||||||
<a href="#advanced" onclick={(e) => { e.preventDefault(); activeTab = 'advanced'; }}>Advanced</a>
|
|
||||||
</li>
|
|
||||||
</menu>
|
|
||||||
|
|
||||||
<div class="window" role="tabpanel">
|
<section class="settings-content">
|
||||||
<div class="window-body">
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
|
||||||
|
The <menu> element IS interactive (contains clickable <button> elements) and the
|
||||||
|
role="tablist" properly describes the semantic purpose to assistive technology.
|
||||||
|
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
|
||||||
|
-->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={activeTab === 'library'}>
|
||||||
|
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={activeTab === 'deezer'}>
|
||||||
|
<a href="#deezer" onclick={(e) => { e.preventDefault(); activeTab = 'deezer'; }}>Deezer</a>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={activeTab === 'advanced'}>
|
||||||
|
<a href="#advanced" onclick={(e) => { e.preventDefault(); activeTab = 'advanced'; }}>Advanced</a>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
{#if activeTab === 'library'}
|
{#if activeTab === 'library'}
|
||||||
<section class="tab-content">
|
<section>
|
||||||
<h3>Library Folders</h3>
|
<h3>Library Folders</h3>
|
||||||
<div class="field-row-stacked">
|
<div class="field-row-stacked">
|
||||||
<label for="music-folder">Music Folder</label>
|
<label for="music-folder">Music Folder</label>
|
||||||
@@ -197,7 +216,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if activeTab === 'deezer'}
|
{:else if activeTab === 'deezer'}
|
||||||
<section class="tab-content">
|
<section>
|
||||||
<h3>Deezer Download Settings</h3>
|
<h3>Deezer Download Settings</h3>
|
||||||
|
|
||||||
<div class="field-row-stacked">
|
<div class="field-row-stacked">
|
||||||
@@ -319,7 +338,7 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
{:else if activeTab === 'advanced'}
|
{:else if activeTab === 'advanced'}
|
||||||
<section class="tab-content">
|
<section>
|
||||||
<h3>Advanced Settings</h3>
|
<h3>Advanced Settings</h3>
|
||||||
|
|
||||||
<div class="field-row-stacked">
|
<div class="field-row-stacked">
|
||||||
@@ -339,16 +358,28 @@
|
|||||||
<small class="help-text">This will delete all cached Deezer favorites data. The next time you visit the Deezer page, it will refetch from the API.</small>
|
<small class="help-text">This will delete all cached Deezer favorites data. The next time you visit the Deezer page, it will refetch from the API.</small>
|
||||||
<button onclick={clearDeezerDatabase}>Clear Deezer Cache</button>
|
<button onclick={clearDeezerDatabase}>Clear Deezer Cache</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<div class="setting-heading">Open App Data Folder</div>
|
||||||
|
<small class="help-text">Opens the application data folder containing SQLite databases and other app data.</small>
|
||||||
|
<button onclick={openAppDataFolder}>Open Folder</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.settings-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-top: 0;
|
margin: 0;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -357,12 +388,27 @@
|
|||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
menu[role="tablist"] {
|
.settings-content {
|
||||||
margin-bottom: 0;
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
margin: 0;
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content .window-body {
|
||||||
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-note {
|
.info-note {
|
||||||
|
|||||||
608
src/routes/sync/+page.svelte
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { exists } from '@tauri-apps/plugin-fs';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import {
|
||||||
|
deviceSyncSettings,
|
||||||
|
loadDeviceSyncSettings,
|
||||||
|
setMusicPath,
|
||||||
|
setPlaylistsPath,
|
||||||
|
setOverwriteMode,
|
||||||
|
type OverwriteMode
|
||||||
|
} from '$lib/stores/deviceSync';
|
||||||
|
import {
|
||||||
|
indexAndCompare,
|
||||||
|
syncToDevice,
|
||||||
|
formatBytes,
|
||||||
|
type SyncDiff,
|
||||||
|
type SyncProgress
|
||||||
|
} from '$lib/services/deviceSync';
|
||||||
|
|
||||||
|
type ViewMode = 'sync' | 'preferences';
|
||||||
|
|
||||||
|
let viewMode = $state<ViewMode>('sync');
|
||||||
|
let configured = $state(false);
|
||||||
|
let deviceConnected = $state(false);
|
||||||
|
let checkingConnection = $state(false);
|
||||||
|
|
||||||
|
// Path inputs for initial setup
|
||||||
|
let musicPathInput = $state('');
|
||||||
|
let playlistsPathInput = $state('');
|
||||||
|
|
||||||
|
// Sync state
|
||||||
|
let indexing = $state(false);
|
||||||
|
let syncing = $state(false);
|
||||||
|
let syncDiff = $state<SyncDiff | null>(null);
|
||||||
|
let syncProgress = $state<SyncProgress | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let successMessage = $state<string | null>(null);
|
||||||
|
let selectedFileIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadDeviceSyncSettings();
|
||||||
|
configured = !!$deviceSyncSettings.musicPath;
|
||||||
|
|
||||||
|
if (configured) {
|
||||||
|
await checkDeviceConnection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkDeviceConnection() {
|
||||||
|
if (!$deviceSyncSettings.musicPath) {
|
||||||
|
deviceConnected = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkingConnection = true;
|
||||||
|
try {
|
||||||
|
deviceConnected = await exists($deviceSyncSettings.musicPath);
|
||||||
|
} catch (e) {
|
||||||
|
deviceConnected = false;
|
||||||
|
} finally {
|
||||||
|
checkingConnection = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBrowseMusicPath() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Device Music Folder'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
musicPathInput = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBrowsePlaylistsPath() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Device Playlists Folder'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
playlistsPathInput = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveConfiguration() {
|
||||||
|
if (!musicPathInput) {
|
||||||
|
error = 'Please select a music folder path';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
await setMusicPath(musicPathInput);
|
||||||
|
await setPlaylistsPath(playlistsPathInput || null);
|
||||||
|
|
||||||
|
configured = true;
|
||||||
|
await checkDeviceConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIndexAndCompare() {
|
||||||
|
if (!$settings.musicFolder) {
|
||||||
|
error = 'No library music folder configured. Please set one in Settings.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$deviceSyncSettings.musicPath) {
|
||||||
|
error = 'No device music path configured.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
indexing = true;
|
||||||
|
error = null;
|
||||||
|
successMessage = null;
|
||||||
|
syncDiff = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checkDeviceConnection();
|
||||||
|
if (!deviceConnected) {
|
||||||
|
throw new Error('Device is not connected or path does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await indexAndCompare(
|
||||||
|
$settings.musicFolder,
|
||||||
|
$deviceSyncSettings.musicPath,
|
||||||
|
$deviceSyncSettings.overwriteMode
|
||||||
|
);
|
||||||
|
|
||||||
|
syncDiff = result;
|
||||||
|
successMessage = `Found ${result.filesToCopy.length} files to sync`;
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Error indexing device: ' + (e instanceof Error ? e.message : String(e));
|
||||||
|
syncDiff = null;
|
||||||
|
} finally {
|
||||||
|
indexing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSync() {
|
||||||
|
if (!syncDiff || syncDiff.filesToCopy.length === 0) {
|
||||||
|
error = 'No files to sync. Run Index & Compare first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$settings.musicFolder || !$deviceSyncSettings.musicPath) {
|
||||||
|
error = 'Configuration error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncing = true;
|
||||||
|
error = null;
|
||||||
|
successMessage = null;
|
||||||
|
syncProgress = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncToDevice(
|
||||||
|
$settings.musicFolder,
|
||||||
|
$deviceSyncSettings.musicPath,
|
||||||
|
syncDiff.filesToCopy,
|
||||||
|
(progress) => {
|
||||||
|
syncProgress = progress;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
successMessage = result;
|
||||||
|
syncDiff = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Error syncing to device: ' + (e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
syncProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateMusicPath() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Device Music Folder'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
await setMusicPath(selected);
|
||||||
|
await checkDeviceConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdatePlaylistsPath() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Device Playlists Folder'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
await setPlaylistsPath(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileClick(index: number) {
|
||||||
|
selectedFileIndex = index;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sync-wrapper">
|
||||||
|
<h2 style="padding: 8px">Device Sync</h2>
|
||||||
|
|
||||||
|
{#if !configured}
|
||||||
|
<!-- Initial Configuration -->
|
||||||
|
<section class="window config-section" style="max-width: 600px; margin: 8px;">
|
||||||
|
<div class="title-bar">
|
||||||
|
<div class="title-bar-text">Configure Device Paths</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-body" style="padding: 12px;">
|
||||||
|
<p>Select the folders on your portable device to sync music and playlists:</p>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="music-path-input">Device Music Folder</label>
|
||||||
|
<div class="input-with-button">
|
||||||
|
<input
|
||||||
|
id="music-path-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={musicPathInput}
|
||||||
|
placeholder="/Volumes/iPod/Music"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button onclick={handleBrowseMusicPath}>Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="playlists-path-input">Device Playlists Folder</label>
|
||||||
|
<div class="input-with-button">
|
||||||
|
<input
|
||||||
|
id="playlists-path-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={playlistsPathInput}
|
||||||
|
placeholder="/Volumes/iPod/Playlists"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button onclick={handleBrowsePlaylistsPath}>Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button onclick={handleSaveConfiguration}>Save Configuration</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if syncing}
|
||||||
|
<div class="sync-status">
|
||||||
|
{#if syncProgress && syncProgress.total > 0}
|
||||||
|
<p class="progress-text">{syncProgress.current} / {syncProgress.total} files</p>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
style="width: {(syncProgress.current / syncProgress.total) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{#if syncProgress.currentFile}
|
||||||
|
<p class="current-file">{syncProgress.currentFile}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p>Syncing...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if indexing}
|
||||||
|
<p style="padding: 8px;">Indexing device...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="error" style="padding: 8px;">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<section class="sync-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
-->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={viewMode === 'sync'}>
|
||||||
|
<button onclick={() => viewMode = 'sync'}>Sync</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'preferences'}>
|
||||||
|
<button onclick={() => viewMode = 'preferences'}>Preferences</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
{#if viewMode === 'sync'}
|
||||||
|
<!-- Sync Tab -->
|
||||||
|
<div class="sync-info">
|
||||||
|
<p>
|
||||||
|
<strong>Device:</strong>
|
||||||
|
{#if checkingConnection}
|
||||||
|
Checking...
|
||||||
|
{:else if deviceConnected}
|
||||||
|
Connected - {$deviceSyncSettings.musicPath}
|
||||||
|
{:else}
|
||||||
|
Not Connected - {$deviceSyncSettings.musicPath}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="button-group">
|
||||||
|
<button
|
||||||
|
onclick={handleIndexAndCompare}
|
||||||
|
disabled={indexing || syncing || !deviceConnected}
|
||||||
|
>
|
||||||
|
{indexing ? 'Indexing...' : 'Index & Compare'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleSync}
|
||||||
|
disabled={!syncDiff || syncDiff.filesToCopy.length === 0 || syncing || !deviceConnected}
|
||||||
|
>
|
||||||
|
{syncing ? 'Syncing...' : 'Sync to Device'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if successMessage}
|
||||||
|
<p class="success">{successMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if syncDiff}
|
||||||
|
<p>
|
||||||
|
New: {syncDiff.stats.newFiles} | Updated: {syncDiff.stats.updatedFiles} |
|
||||||
|
Unchanged: {syncDiff.stats.unchangedFiles} |
|
||||||
|
Total: {formatBytes(syncDiff.stats.totalSize)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if syncDiff && syncDiff.filesToCopy.length > 0}
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each syncDiff.filesToCopy as file, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedFileIndex === i}
|
||||||
|
onclick={() => handleFileClick(i)}
|
||||||
|
>
|
||||||
|
<td>{file.relativePath}</td>
|
||||||
|
<td>{file.status}</td>
|
||||||
|
<td class="size-cell">{formatBytes(file.size)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if viewMode === 'preferences'}
|
||||||
|
<!-- Preferences Tab -->
|
||||||
|
<div class="prefs-container">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Device Paths</legend>
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<div class="field-label">Device Music Path</div>
|
||||||
|
<div class="path-display">
|
||||||
|
<code>{$deviceSyncSettings.musicPath || 'Not set'}</code>
|
||||||
|
<button onclick={handleUpdateMusicPath}>Change...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<div class="field-label">Device Playlists Path</div>
|
||||||
|
<div class="path-display">
|
||||||
|
<code>{$deviceSyncSettings.playlistsPath || 'Not set'}</code>
|
||||||
|
<button onclick={handleUpdatePlaylistsPath}>Change...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style="margin-top: 16px;">
|
||||||
|
<legend>When file exists on device</legend>
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="mode-skip"
|
||||||
|
name="overwrite-mode"
|
||||||
|
value="skip"
|
||||||
|
bind:group={$deviceSyncSettings.overwriteMode}
|
||||||
|
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||||
|
/>
|
||||||
|
<label for="mode-skip">Skip (don't overwrite)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="mode-different"
|
||||||
|
name="overwrite-mode"
|
||||||
|
value="different"
|
||||||
|
bind:group={$deviceSyncSettings.overwriteMode}
|
||||||
|
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||||
|
/>
|
||||||
|
<label for="mode-different">Overwrite if different size</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="mode-always"
|
||||||
|
name="overwrite-mode"
|
||||||
|
value="always"
|
||||||
|
bind:group={$deviceSyncSettings.overwriteMode}
|
||||||
|
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||||
|
/>
|
||||||
|
<label for="mode-always">Always overwrite</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="help-text">
|
||||||
|
System files and _temp folders are always skipped.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sync-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
background: #c0c0c0;
|
||||||
|
border: 2px inset #808080;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #000080, #0000ff);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-file {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #808080;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row-stacked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-button {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-button input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-info {
|
||||||
|
padding: 16px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-info p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-cell {
|
||||||
|
text-align: right;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefs-container {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-display {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-display code {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--button-shadow, #2a2a2a);
|
||||||
|
border: 1px solid var(--button-highlight, #606060);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
static/icons/ipod.svg
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: url(#linear-gradient-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #231f20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: url(#linear-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #bfbebd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #c0c1c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #fefefe;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<linearGradient id="linear-gradient" x1="309.936" y1="731.362" x2="309.936" y2="731.454" gradientTransform="translate(0 1024) scale(1 -1)" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#595858"/>
|
||||||
|
<stop offset="1" stop-color="#aeadad"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linear-gradient-2" x1="512" y1="920.713" x2="512" y2="103.287" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#a5a7aa"/>
|
||||||
|
<stop offset="1" stop-color="#e9e9ea"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-3" d="M309.99,292.52l-.155.073.2.046c-.018-.036-.027-.073-.045-.118Z"/>
|
||||||
|
<path class="cls-1" d="M766.12,806.626l-.209-95.041-.436-302.141-.055-103.741-.045-30.874c-.009-4.009-.245-12.701.145-16.473.7-16.128-.164-33.338.018-49.566.191-16.819.609-34.02-.1-50.82-.673-16.401-6.591-30.229-19.028-41.12-17.492-15.31-39.965-13.573-61.621-13.437l-40.729.227-135.77.273-133.67-.291-42.711-.318c-24.474-.064-43.62-.5-61.157,20.028-13.946,16.328-13.028,36.947-13.019,57.103.009,13.301-.264,26.601.491,39.902.355,5.982-.282,12.064.209,18.146-.745,10.573-.1,28.701-.064,39.938l.091,74.158.027,240.411-.518,190.581-.136,52.684c-.045,10.273-.518,24.583.491,34.538,1.227,11.319,5.964,21.974,13.546,30.465,21.201,24.138,47.902,19.037,76.667,18.946l52.521-.009,146.498-.109,99.577.136,41.375.045c21.537.018,41.029,1.364,58.621-14.182,14.119-12.473,18.192-27.01,18.855-45.375.609-17.855.209-36.183.136-54.084ZM309.836,292.593l.155-.073c.018.045.027.082.045.118l-.2-.046ZM712.382,288.911l-.118,101.296.018,33.492c.009,7.564.136,14.964-.527,22.365-.836,9.182-5.873,19.865-16.31,20.301-18.219.764-36.829.627-55.066.7l-105.705.127-131.906-.2-40.647-.036c-45.584.027-50.83,4.391-50.993-44.775l-.073-31.32-.064-108.896.082-37.874c-.009-6.018.182-13.992-.227-19.846l-.036-.582c.209-12.601.655-25.183.473-37.911-.164-11.546,1.764-27.483,16.51-28.592,4.946-.373,10.055-.318,15.046-.373l27.738-.2,84.149-.064c58.994-.2,117.978-.091,176.972.318,17.946.136,35.911-.082,53.866.155,3.273.045,7.219.091,10.419.518,17.028,2.236,16.073,21.292,16.137,33.811l.091,28.565c.018,4.173-.145,12.437.336,16.346-.773,15.701-.155,36.684-.164,52.675Z"/>
|
||||||
|
<path class="cls-4" d="M310.036,292.639l-.2-.046.155-.073c.018.045.027.082.045.118Z"/>
|
||||||
|
<path class="cls-6" d="M509.217,539.412c80.027-1.511,146.156,62.085,147.769,142.111,1.613,80.026-61.899,146.235-141.923,147.95-80.168,1.717-146.522-61.933-148.139-142.104-1.616-80.17,62.119-146.443,142.292-147.958Z"/>
|
||||||
|
<path class="cls-5" d="M509.43,630.294c29.875-1.416,55.245,21.646,56.675,51.521,1.429,29.874-21.622,55.254-51.495,56.698-29.893,1.445-55.293-21.625-56.724-51.518-1.431-29.894,21.65-55.283,51.544-56.701Z"/>
|
||||||
|
<path class="cls-2" d="M712.705,288.911l-.118,101.296.018,33.492c.009,7.564.136,14.964-.527,22.365-.836,9.182-5.873,19.865-16.31,20.301-18.219.764-36.829.627-55.066.7l-105.705.127-131.906-.2-40.647-.036c-45.584.027-50.83,4.391-50.993-44.775l-.073-31.32-.064-108.896.082-37.874c-.009-6.018.182-13.992-.227-19.846l-.036-.582c.209-12.601.655-25.183.473-37.911-.164-11.546,1.764-27.483,16.51-28.592,4.946-.373,10.055-.318,15.046-.373l27.738-.2,84.149-.064c58.994-.2,117.978-.091,176.972.318,17.946.136,35.911-.082,53.866.155,3.273.045,7.219.091,10.419.518,17.028,2.236,16.073,21.292,16.137,33.811l.091,28.565c.018,4.173-.145,12.437.336,16.346-.773,15.701-.155,36.684-.164,52.675Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
36
static/icons/lrclib-logo.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #111041;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #fdfdfd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="QGdPvo.tif">
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M16.663,126.482C7.066,121.586,1.49,116.223,0,104.918V23.048C1.071,14.257,6.144,6.818,13.95,2.709c.515-.271,1.772-.711,3.089-1.26l1.247-.473C19.133.695,21.597.003,23.055,0h81.726c12.256,1.082,21.845,10.748,23.218,22.883l-.029,82.497c-1.337,10.446-8.245,18.709-18.356,21.676-.66.194-3.565.748-3.753.787-.191.04-.422.039-.627.073l-82.5.039c-1.539-.285-4.031-.886-4.833-1.085s-1.241-.389-1.241-.389ZM101.882,30.168c.536-.844-1.616-4.825-2.279-5.633-3.824-4.665-12.876-4.358-17.059-.33-5.699,5.488-5.939,20.919-.088,26.331,4.514,4.175,13.651,3.675,17.647-1.066.837-.993,3.255-4.753,1.639-5.583-.465-.239-5.132-1.468-5.462-1.368-.442.134-.908,1.926-1.294,2.49-1.213,1.769-3.804,2.347-5.808,1.761-4.917-1.438-4.464-13.841-2.336-17.285,1.674-2.708,5.696-3.17,7.586-.432.509.738.686,2.309,1.433,2.506.299.079,5.856-1.13,6.021-1.391ZM46.689,46.266h-11.65l-.246-.246v-24.114c0-.186-.672-.521-.913-.564-.655-.116-5.135-.084-5.608.114-.2.084-.435.269-.502.483l-.076,30.358.41.41,18.885.093c.342-.122.401-.565.441-.872.093-.711.076-4.809-.184-5.234-.078-.128-.426-.398-.557-.427ZM58.257,51.926v-10.991l.246-.246h3.446c.68,0,5.287,10.795,6.181,12.111.272.158,5.816.124,6.285.024.325-.069.597-.346.676-.65.287-1.101-5.527-10.991-6.005-12.888,1.046-.912,2.315-1.428,3.287-2.612,4.498-5.479.959-13.534-5.907-14.938-2.907-.594-11.097-.796-14.037-.408-.632.084-.93.259-1.053.916l.025,29.809c.117.379.295.683.707.77.543.115,5.498.041,5.754-.145.093-.067.373-.63.395-.753ZM97.498,85.496c.047-.09,1.235-.965,1.536-1.333,2.362-2.89,2.058-8.084-.66-10.663-3.188-3.025-11.839-2.811-16.085-2.633-.561.024-3.636.2-3.811.374l-.05,30.729.741.408c7.002-.546,20.72,2.585,22.058-7.492.348-2.626.038-5.033-1.701-7.117-.403-.483-2.268-1.812-2.028-2.272ZM46.896,96.011c-.662-.664-11.744.254-12.284-.433-.152-.194-.16-.429-.157-.663l-.027-23.267c-.083-.326-.176-.534-.538-.611-.571-.122-5.752-.039-6.081.153l-.148,30.845,18.961.262c.135-.04.247-.079.328-.203.269-.413.248-5.782-.053-6.084ZM58.703,71.073c-.327.095-.628.511-.619.858l.044,29.664c.117.481.41.675.878.763.698.131,4.515.126,5.229,0,.475-.083.929-.356.921-.884l-.013-29.694c-.079-.37-.368-.659-.738-.738-.47-.1-5.305-.084-5.701.031Z"/>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M46.896,96.011c.301.302.322,5.671.053,6.084-.081.124-.193.163-.328.203l-18.961-.262.148-30.845c.329-.191,5.51-.274,6.081-.153.361.077.455.285.538.611l.027,23.267c-.003.234.005.469.157.663.54.687,11.622-.231,12.284.433Z"/>
|
||||||
|
<path class="cls-2" d="M58.703,71.073c.396-.115,5.232-.131,5.701-.031.37.079.66.368.738.738l.013,29.694c.008.528-.446.801-.921.884-.714.125-4.531.131-5.229,0-.468-.088-.761-.282-.878-.763l-.044-29.664c-.009-.347.292-.763.619-.858Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M85.167,96.3v-7.382h5.825c.15,0,1.228.41,1.447.522,3.393,1.743,1.775,6.86-1.447,6.86h-5.825Z"/>
|
||||||
|
<path class="cls-1" d="M85.167,83.012v-5.906h5.989c.325,0,1.698.849,1.959,1.158,1.689,2.004-.298,4.747-2.615,4.747h-5.333Z"/>
|
||||||
|
<path class="cls-2" d="M97.498,85.496c-.24.46,1.625,1.789,2.028,2.272,1.738,2.084,2.049,4.492,1.701,7.117-1.338,10.078-15.056,6.947-22.058,7.492l-.741-.408.05-30.729c.175-.174,3.249-.35,3.811-.374,4.246-.178,12.896-.392,16.085,2.633,2.718,2.579,3.022,7.772.66,10.663-.301.368-1.489,1.243-1.536,1.333ZM85.167,83.012h5.333c2.317,0,4.304-2.743,2.615-4.747-.261-.31-1.634-1.158-1.959-1.158h-5.989v5.906ZM85.167,96.3h5.825c3.222,0,4.84-5.117,1.447-6.86-.219-.112-1.297-.522-1.447-.522h-5.825v7.382Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M58.257,51.926c-.022.123-.302.686-.395.753-.256.185-5.211.259-5.754.145-.412-.087-.589-.391-.707-.77l-.025-29.809c.123-.657.421-.832,1.053-.916,2.94-.389,11.13-.187,14.037.408,6.866,1.404,10.405,9.459,5.907,14.938-.972,1.184-2.24,1.7-3.287,2.612.478,1.897,6.291,11.786,6.005,12.888-.079.304-.351.58-.676.65-.469.1-6.013.133-6.285-.024-.894-1.316-5.501-12.111-6.181-12.111h-3.446l-.246.246v10.991ZM58.257,34.619h5.005c1.232,0,3.441-1.02,3.682-2.388.12-.682.075-2.405-.251-3.011-.288-.536-1.879-1.655-2.447-1.655h-5.907c-.639,0,.119,6.472-.082,7.054Z"/>
|
||||||
|
<path class="cls-2" d="M101.882,30.168c-.166.261-5.723,1.47-6.021,1.391-.747-.197-.924-1.768-1.433-2.506-1.89-2.738-5.912-2.277-7.586.432-2.128,3.444-2.581,15.847,2.336,17.285,2.004.586,4.596.008,5.808-1.761.386-.563.852-2.356,1.294-2.49.331-.1,4.997,1.129,5.462,1.368,1.616.829-.802,4.59-1.639,5.583-3.996,4.741-13.133,5.241-17.647,1.066-5.851-5.412-5.611-20.843.088-26.331,4.183-4.028,13.235-4.335,17.059.33.663.808,2.815,4.789,2.279,5.633Z"/>
|
||||||
|
<path class="cls-2" d="M46.689,46.266c.131.029.479.299.557.427.259.426.277,4.523.184,5.234-.04.307-.099.75-.441.872l-18.885-.093-.41-.41.076-30.358c.067-.213.302-.399.502-.483.473-.198,4.953-.23,5.608-.114.241.042.913.378.913.564v24.114l.246.246h11.65Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path class="cls-1" d="M58.257,34.619c.201-.582-.557-7.054.082-7.054h5.907c.568,0,2.159,1.118,2.447,1.655.325.606.371,2.329.251,3.011-.241,1.368-2.451,2.388-3.682,2.388h-5.005Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
3
static/icons/pause.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 4H10V20H6V4ZM14 4H18V20H14V4Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 201 B |
@@ -1,3 +1,5 @@
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 20H8V4H10V6H12V9H14V11H16V13H14V15H12V18H10V20Z" fill="black"/>
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" version="1.1" viewBox="0 0 24 24">
|
||||||
</svg>
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<path d="M10,20h-2V4h2v2h2v3h2v2h2v2h-2v2h-2v3h-2v2Z" fill="#fff" fill-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 344 B |
5
static/icons/player-pause.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<path d="M224,432h-80V80h80v352ZM368,432h-80V80h80v352Z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 331 B |
5
static/icons/player-play.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<path d="M96,448l320-192L96,64v384Z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 311 B |
5
static/icons/player-skip-back.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<path d="M143.47,64v163.52L416,64v384l-272.53-163.51999v163.51999h-47.47V64h47.47Z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 358 B |
5
static/icons/player-skip-forward.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<path d="M368.53,64v163.52L96,64v384l272.53-163.52002v163.52002h47.47V64h-47.47Z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 356 B |
5
static/icons/player-stop.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<path d="M80,80h352v352H80V80Z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 306 B |
11
static/vectors/title-decoration.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #373737;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-1" d="M64,0H0v32h21.634c3.056-9.369,6.236-15.502,19.82-17.258,2.105-.272,4.23-.37,6.352-.37h16.193V0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 353 B |