Compare commits

...

38 Commits

Author SHA1 Message Date
3d8df1eb48 fix(spotify): handle possible undefined access and refresh tokens 2025-10-16 13:45:32 -04:00
085f58e40f fix(ui): update device path labels to div elements 2025-10-16 13:45:24 -04:00
72bc53e495 refactor(ui): remove in-library status from spotify collection view 2025-10-16 13:36:16 -04:00
651d87af4c feat(spotify): hook existing download queue 2025-10-16 13:25:03 -04:00
df4967dd55 fix(ui): update track number styling and playlist track mapping 2025-10-16 12:37:00 -04:00
1bffafad44 feat(spotify): library caching 2025-10-16 11:27:08 -04:00
e19c25e94b refactor(auth): style OAuth callback and clean up logic 2025-10-15 13:07:03 -04:00
7f719bec11 feat(wip): add tauri-plugin-oauth and enable Spotify oauth 2025-10-15 12:53:51 -04:00
8d773f8188 feat(device): add device sync button 2025-10-15 11:45:52 -04:00
af4f8ce77f icons: ipod 2025-10-12 20:11:44 -04:00
a4f5bdd7a8 docs: changelog for first release 2025-10-05 16:52:03 -04:00
456f854863 refactor(ui): update albums interaction to double click for consistency 2025-10-05 01:40:50 -04:00
3118d969c6 refctor(ui): make tabs layout consistent with the rest of the app in settings and queue 2025-10-05 01:40:14 -04:00
17b6f7958e fix(queue): reset interrupted downloads and clear current job on load 2025-10-05 01:25:42 -04:00
cba49ce411 feat(library): add ipod-safe emoji encoding for playlist names 2025-10-05 01:07:22 -04:00
369ea9df02 feat(services): improve lyrics fetch progress and status updates 2025-10-05 00:49:51 -04:00
ca5f79b23a feat(settings): add button to open app data folder 2025-10-05 00:17:33 -04:00
8fb27b1acd feat(db): add tracks table and lyric scan caching 2025-10-05 00:17:19 -04:00
25ce2d676e feat(services): add LRCLIB service, scan utility, and context menus 2025-10-04 23:56:58 -04:00
38db835973 feat(ui): add reactive status bar with notifications 2025-10-04 23:36:09 -04:00
c30b205d9c feat(ui): add page decoration component for collection views 2025-10-04 22:25:07 -04:00
7b84bc32df fix(dl): add progress events for tracks from new downloader 2025-10-04 20:58:34 -04:00
96a01bdced refactor: move download/decryption to backend to fix UI freezing
Now implements streaming download+decryption entirely in Rust:
- Added reqwest/tokio/futures-util dependencies
- Created StreamingDecryptor for chunk-by-chunk decryption
- New download_and_decrypt_track command streams to disk directly
- Frontend simplified to single invoke() call
2025-10-04 20:53:59 -04:00
e4586f6497 fix: incorrect license in package 2025-10-04 20:46:01 -04:00
f4ef13ec0d fix(db): playlist username fallback handling 2025-10-04 20:38:59 -04:00
05acc3483c refactor(ui): remove disabled GitHub button from toolbar 2025-10-04 20:26:21 -04:00
efaa9f02b8 fix(tauri): update content security policy to include media sources 2025-10-04 16:23:05 -04:00
e535fdb4bc refactor(ui): housekeeping 2025-10-04 16:21:33 -04:00
8b3989e71f fix: lyrics not saved in new queue 2025-10-04 16:04:46 -04:00
9e75322a43 refactor(np): refactor layout 2025-10-04 15:49:24 -04:00
a602ee4bbd refactor(np): layout 2025-10-04 15:29:30 -04:00
9333e55095 refactor(np): now playing controls and icons 2025-10-04 15:19:24 -04:00
e5c8ce1c30 fix: volume slider 2025-10-04 15:05:06 -04:00
7c64818db1 refactor(np): add triangle volume slider to now playing panel 2025-10-04 15:01:19 -04:00
480aa5859b fix: path sanitization inconsistency in cover art lookup
Cover art lookup was constructing paths from raw metadata without
sanitization, causing "No such file or directory" errors for artists
with special characters (e.g. "Au/Ra" looked for "Au/Ra/" but files
were saved to "Au_Ra/"). Now uses sanitizeFilename() to match the
actual on-disk folder structure created during downloads.
2025-10-04 14:43:54 -04:00
26c465118b fix: missing cover art in playlist downloads
Playlist downloads were not fetching album cover URLs, causing both
embedded cover art and folder cover.jpg files to be skipped. Queue
manager now fetches album data on-demand (only when cover art is
enabled) to get cover URLs, reusing the same logic as individual track
downloads. Fetches track data first if albumId is missing.
2025-10-04 14:43:37 -04:00
fc2b987f63 feat(ui): add now playing panel and context menu for tracks 2025-10-03 20:59:37 -04:00
a7fc6e8d5d feat(ui): sequential playlist track numbering and banner panel 2025-10-03 20:12:03 -04:00
66 changed files with 8138 additions and 219 deletions

82
CHANGELOG.md Normal file
View 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.

View File

@@ -4,12 +4,14 @@
"": {
"name": "shark",
"dependencies": {
"@fabianlars/tauri-plugin-oauth": "2",
"@noble/ciphers": "^2.0.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-sql": "^2.3.0",
"@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=="],
"@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/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-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-sql": ["@tauri-apps/plugin-sql@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-JYwIocfsLaDWa41LMiZWuzts7yCJR+EpZPRmgpO7Gd7XiAS9S67dKz306j/k/d9XntB0YopMRBol2OIWMschuA=="],

View File

@@ -11,14 +11,17 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@fabianlars/tauri-plugin-oauth": "2",
"@noble/ciphers": "^2.0.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-sql": "^2.3.0",
"@tauri-apps/plugin-store": "~2",

214
src-tauri/Cargo.lock generated
View File

@@ -8,9 +8,11 @@ version = "0.1.0"
dependencies = [
"blowfish",
"byteorder",
"futures-util",
"id3",
"md5",
"metaflac",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -18,10 +20,15 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-oauth",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-sql",
"tauri-plugin-store",
"tokio",
"unicode-normalization",
"walkdir",
]
[[package]]
@@ -659,7 +666,7 @@ dependencies = [
"bitflags 2.9.4",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1192,6 +1199,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "foreign-types"
version = "0.5.0"
@@ -1199,7 +1215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1213,6 +1229,12 @@ dependencies = [
"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]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1453,6 +1475,16 @@ dependencies = [
"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]]
name = "getrandom"
version = "0.1.16"
@@ -1844,6 +1876,22 @@ dependencies = [
"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]]
name = "hyper-util"
version = "0.1.17"
@@ -2464,6 +2512,23 @@ dependencies = [
"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]]
name = "ndk"
version = "0.9.0"
@@ -2858,6 +2923,50 @@ dependencies = [
"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]]
name = "option-ext"
version = "0.2.0"
@@ -2874,6 +2983,18 @@ dependencies = [
"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]]
name = "pango"
version = "0.18.3"
@@ -3579,10 +3700,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3593,6 +3716,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3755,6 +3879,15 @@ dependencies = [
"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]]
name = "schemars"
version = "0.8.22"
@@ -3818,6 +3951,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "selectors"
version = "0.24.0"
@@ -4116,7 +4272,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@@ -4479,6 +4635,15 @@ dependencies = [
"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]]
name = "system-configuration"
version = "0.6.1"
@@ -4767,6 +4932,21 @@ dependencies = [
"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]]
name = "tauri-plugin-opener"
version = "2.5.0"
@@ -4789,6 +4969,24 @@ dependencies = [
"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]]
name = "tauri-plugin-process"
version = "2.3.0"
@@ -5085,6 +5283,16 @@ dependencies = [
"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]]
name = "tokio-rustls"
version = "0.26.4"

View File

@@ -33,4 +33,11 @@ tauri-plugin-process = "2"
blowfish = "0.9"
md5 = "0.7"
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"

View File

@@ -8,6 +8,20 @@
"permissions": [
"core:default",
"opener:default",
{
"identifier": "opener:allow-open-path",
"allow": [
{
"path": "$APPDATA"
},
{
"path": "$APPCONFIG"
},
{
"path": "$APPLOCALDATA"
}
]
},
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
@@ -55,11 +69,31 @@
},
{
"url": "http://*.dzcdn.net/**"
},
{
"url": "https://lrclib.net/**"
},
{
"url": "https://accounts.spotify.com/**"
},
{
"url": "https://api.spotify.com/**"
}
]
},
"sql:default",
"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/*"
}
]
}
]
}

View File

@@ -23,8 +23,7 @@ pub fn generate_blowfish_key(track_id: &str) -> Vec<u8> {
/// Decrypt a single 2048-byte chunk using Blowfish CBC
pub fn decrypt_chunk(chunk: &[u8], blowfish_key: &[u8]) -> Vec<u8> {
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key)
.expect("Invalid key length");
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key).expect("Invalid key length");
let mut result = chunk.to_vec();
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
}
/// 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)]
mod tests {
use super::*;

View 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))
}

View File

@@ -1,8 +1,9 @@
use tauri_plugin_sql::{Migration, MigrationKind};
mod tagger;
mod metadata;
mod deezer_crypto;
mod device_sync;
mod metadata;
mod tagger;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
@@ -36,7 +37,7 @@ fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String>
metadata::read_audio_metadata(&path)
}
/// Decrypt Deezer track data
/// Decrypt Deezer track data (legacy - kept for backwards compatibility)
#[tauri::command]
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
@@ -49,6 +50,135 @@ async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>
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)]
pub fn run() {
let library_migrations = vec![Migration {
@@ -80,10 +210,25 @@ pub fn run() {
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_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_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,
}];
@@ -154,12 +299,83 @@ pub fn run() {
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()
.plugin(tauri_plugin_oauth::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init())
.plugin(
tauri_plugin_sql::Builder::new()
.add_migrations("sqlite:library.db", library_migrations)
.add_migrations("sqlite:deezer.db", deezer_migrations)
.add_migrations("sqlite:spotify.db", spotify_migrations)
.build(),
)
.plugin(tauri_plugin_http::init())
@@ -171,7 +387,10 @@ pub fn run() {
greet,
tag_audio_file,
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!())
.expect("error while running tauri application");

View File

@@ -1,5 +1,5 @@
use metaflac::Tag as FlacTag;
use id3::{Tag as ID3Tag, TagLike};
use metaflac::Tag as FlacTag;
use serde::{Deserialize, Serialize};
use std::path::Path;
@@ -40,8 +40,8 @@ pub fn read_audio_metadata(path: &str) -> Result<AudioMetadata, String> {
/// Read metadata from MP3 file
fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
let tag = ID3Tag::read_from_path(path)
.map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
let tag =
ID3Tag::read_from_path(path).map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
Ok(AudioMetadata {
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
fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
let tag = FlacTag::read_from_path(path)
.map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
let tag =
FlacTag::read_from_path(path).map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
// Helper to get first value from vorbis comment
let get_first = |key: &str| -> Option<String> {
@@ -66,8 +66,7 @@ fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
};
// Parse track number
let track_number = get_first("TRACKNUMBER")
.and_then(|s| s.parse::<u32>().ok());
let track_number = get_first("TRACKNUMBER").and_then(|s| s.parse::<u32>().ok());
// Get duration from streaminfo block (in samples)
let duration = tag.get_streaminfo().map(|info| {

View File

@@ -19,7 +19,7 @@
}
],
"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": {
"enable": true,
"scope": ["**"]

View File

@@ -1,13 +1,15 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
let { onToggleNowPlaying }: { onToggleNowPlaying?: () => void } = $props();
const icons = {
back: '/icons/leftarrow.png',
forward: '/icons/rightarrow.png',
play: '/icons/speaker.png',
search: '/icons/internet.png',
globe: '/icons/github-white.svg',
computer: '/icons/computer.png',
device: '/icons/ipod.svg',
};
let history: string[] = $state([]);
@@ -36,6 +38,12 @@
}
}
function handleNowPlayingClick() {
if (onToggleNowPlaying) {
onToggleNowPlaying();
}
}
$effect(() => {
if (history.length === 0 && typeof window !== 'undefined') {
history = [window.location.pathname];
@@ -64,7 +72,7 @@
<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" />
<span>Now Playing</span>
</button>
@@ -74,17 +82,16 @@
<span>Search</span>
</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">
<img src={icons.computer} alt="Settings" />
<span>Settings</span>
</a>
<div class="toolbar-separator"></div>
<button class="toolbar-button" disabled title="GitHub">
<img src={icons.globe} alt="Globe" />
<span>GitHub</span>
</button>
</div>
<style>

View File

@@ -1,6 +1,11 @@
<script lang="ts">
import type { Track } from '$lib/types/track';
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 {
title: string;
@@ -11,6 +16,8 @@
selectedTrackIndex?: number | null;
onTrackClick?: (index: number) => void;
showAlbumColumn?: boolean;
useSequentialNumbers?: boolean;
decorationLabel?: string;
}
let {
@@ -21,9 +28,13 @@
tracks,
selectedTrackIndex = null,
onTrackClick,
showAlbumColumn = false
showAlbumColumn = false,
useSequentialNumbers = false,
decorationLabel = 'LOCAL PLAYLIST'
}: Props = $props();
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
function getThumbnailUrl(coverPath?: string): string {
if (!coverPath) {
return '';
@@ -36,8 +47,71 @@
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>
<PageDecoration label={decorationLabel} />
<!-- Header -->
<div class="collection-header">
{#if coverArtPath}
@@ -98,9 +172,11 @@
<tr
class:highlighted={selectedTrackIndex === i}
onclick={() => handleTrackClick(i)}
ondblclick={() => handleTrackDoubleClick(i)}
oncontextmenu={(e) => handleContextMenu(e, i)}
>
<td class="track-number">
{track.metadata.trackNumber ?? i + 1}
{useSequentialNumbers ? i + 1 : (track.metadata.trackNumber ?? i + 1)}
</td>
<td>{track.metadata.title ?? '—'}</td>
{#if showAlbumColumn}
@@ -124,6 +200,15 @@
</div>
</section>
{#if contextMenu}
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getContextMenuItems(contextMenu.trackIndex)}
onClose={() => contextMenu = null}
/>
{/if}
<style>
.collection-header {
display: flex;
@@ -206,6 +291,13 @@
width: 100%;
}
thead {
position: sticky;
top: 0;
z-index: 1;
background: #121212;
}
th {
text-align: left;
}

View 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>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Track } from '$lib/types/track';
import PageDecoration from '$lib/components/PageDecoration.svelte';
interface Props {
title: string;
@@ -64,6 +65,8 @@
}
</script>
<PageDecoration label="DEEZER PLAYLIST" />
<!-- Header -->
<div class="collection-header">
{#if coverImageUrl}
@@ -277,7 +280,7 @@
.track-number {
text-align: center;
opacity: 0.6;
z-index: 0;
}
.duration {

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -24,6 +24,19 @@ export interface DbAlbum {
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;
/**
@@ -231,3 +244,82 @@ export async function getLibraryStats(): Promise<{
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
);
}

View File

@@ -115,7 +115,7 @@ export async function getCachedArtists(): Promise<DeezerArtist[]> {
export async function getCachedTracks(): Promise<DeezerTrack[]> {
const database = await initDeezerDatabase();
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 || [];
}
@@ -146,7 +146,7 @@ export async function upsertPlaylists(playlists: any[]): Promise<void> {
String(playlist.PLAYLIST_ID),
playlist.TITLE || '',
playlist.NB_SONG || 0,
playlist.PARENT_USERNAME || 'Unknown',
playlist.PARENT_USERNAME || playlist._USER_NAME_FALLBACK || 'Unknown',
playlist.PLAYLIST_PICTURE || null,
playlist.PICTURE_TYPE || null,
now

View 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
}));
}

View File

@@ -1,5 +1,6 @@
import { writeFile } from '@tauri-apps/plugin-fs';
import { sanitizeFilename } from '$lib/services/deezer/paths';
import { encodeEmojis } from '$lib/utils/emoji';
export interface M3U8Track {
duration: number; // in seconds
@@ -22,14 +23,15 @@ export async function writeM3U8(
tracks: M3U8Track[],
playlistsFolder: string
): Promise<string> {
// Sanitize playlist name for filename
const sanitizedName = sanitizeFilename(playlistName);
// Encode emojis and sanitize playlist name for filename
const encodedName = encodeEmojis(playlistName);
const sanitizedName = sanitizeFilename(encodedName);
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
// Build m3u8 content
const lines: string[] = [
'#EXTM3U',
`#PLAYLIST:${playlistName}`,
`#PLAYLIST:${encodedName}`,
'#EXTENC:UTF-8',
''
];

View File

@@ -2,6 +2,8 @@ import { readTextFile, exists, readDir } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core';
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
import { findAlbumArt } from './album';
import { sanitizeFilename } from '$lib/services/deezer/paths';
import { decodeEmojis } from '$lib/utils/emoji';
/**
* 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
* 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++) {
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'))) {
continue;
}
@@ -228,7 +254,8 @@ export async function findPlaylistCoverFallback(
}
// 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 {
// Check if album folder exists and has cover art

View File

@@ -1,6 +1,7 @@
import { readDir, readFile } from '@tauri-apps/plugin-fs';
import { parseBuffer } from 'music-metadata';
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
import { parsePlaylistName } from './playlist';
export interface Artist {
name: string;
@@ -283,11 +284,18 @@ export async function scanPlaylists(playlistsFolderPath: string): Promise<Playli
if (!entry.isDirectory) {
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
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 displayName = metadataName || nameWithoutExt;
playlists.push({
name: nameWithoutExt,
path: `${playlistsFolderPath}/${entry.name}`
name: displayName,
path: playlistPath
});
}
}

View 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;
}
}

View 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;
}
});
}

View File

@@ -340,7 +340,15 @@ export class DeezerAPI {
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) {
console.error('Error fetching playlists:', error);
return [];

View File

@@ -8,6 +8,7 @@ import { addToQueue } from '$lib/stores/downloadQueue';
import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { trackExists } from './downloader';
import { setInfo, setWarning } from '$lib/stores/status';
import { get } from 'svelte/store';
/**
@@ -103,6 +104,7 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
if (exists) {
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
setWarning(`Skipped: ${track.title} (already exists)`);
return { added: false, reason: 'already_exists' };
}
}
@@ -117,5 +119,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
downloadObject: track
});
setInfo(`Queued: ${track.title}`);
return { added: true };
}

View File

@@ -2,7 +2,6 @@
* 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 { invoke } from '@tauri-apps/api/core';
import { generateTrackPath } from './paths';
@@ -32,6 +31,7 @@ export async function downloadTrack(
retryCount: number = 0,
decryptionTrackId?: string
): Promise<string> {
const { listen } = await import('@tauri-apps/api/event');
// Generate paths
const paths = generateTrackPath(track, musicFolder, format, false);
@@ -56,85 +56,32 @@ export async function downloadTrack(
console.log('Temp path:', paths.tempPath);
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/');
// Stream the response with progress tracking
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
let downloadedBytes = 0;
let lastReportedPercentage = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
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
const encryptedData = new Uint8Array(downloadedBytes);
let offset = 0;
for (const chunk of chunks) {
encryptedData.set(chunk, offset);
offset += chunk.length;
}
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
// Set up progress listener
const unlisten = await listen<DownloadProgress>('download-progress', (event) => {
if (onProgress) {
onProgress(event.payload);
}
});
decryptedData = new Uint8Array(decryptedArray);
} else {
decryptedData = encryptedData;
try {
// Download and decrypt in Rust backend (streaming, no memory accumulation)
console.log('Downloading and decrypting track in Rust backend...');
await invoke('download_and_decrypt_track', {
url: downloadURL,
trackId: trackIdForDecryption,
outputPath: paths.tempPath,
isEncrypted: isCrypted
});
console.log('Download and decryption complete!');
} finally {
// Clean up event listener
unlisten();
}
// Get user settings
@@ -151,10 +98,7 @@ export async function downloadTrack(
}
}
// Write untagged file to temp first
console.log('Writing untagged file to temp...');
await writeFile(paths.tempPath, decryptedData);
// File is already written to temp by Rust backend
// Move to final location
const finalPath = `${paths.filepath}/${paths.filename}`;
console.log('Moving to final location:', finalPath);

View File

@@ -7,6 +7,7 @@ import { trackExists } from './downloader';
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
import { generateTrackPath } from './paths';
import { settings } from '$lib/stores/settings';
import { setInfo, setSuccess } from '$lib/stores/status';
import { get } from 'svelte/store';
import type { DeezerTrack } from '$lib/types/deezer';
import { mkdir } from '@tauri-apps/plugin-fs';
@@ -76,6 +77,15 @@ export async function downloadDeezerPlaylist(
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
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
// Generate expected path for this track
@@ -98,5 +108,8 @@ export async function downloadDeezerPlaylist(
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
// Show success message for playlist creation
setSuccess(`Playlist created: ${playlistName}`);
return m3u8Path;
}

View File

@@ -15,6 +15,7 @@ import {
import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { syncTrackPaths } from '$lib/library/incrementalSync';
import { setSuccess, setError } from '$lib/stores/status';
import { get } from 'svelte/store';
import type { DeezerTrack } from '$lib/types/deezer';
@@ -121,9 +122,6 @@ export class DeezerQueueManager {
this.abortController = new AbortController();
console.log('[DeezerQueueManager] Starting queue processor');
// Clear any stale currentJob from previous session
await setCurrentJob(null);
try {
await this.processQueue();
} catch (error) {
@@ -182,12 +180,29 @@ export class DeezerQueueManager {
status: 'completed',
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) {
console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error);
await updateQueueItem(nextItem.id, {
status: 'failed',
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
@@ -197,6 +212,86 @@ export class DeezerQueueManager {
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
*/
@@ -215,6 +310,16 @@ export class DeezerQueueManager {
}
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
const userData = await deezerAPI.getUserData();
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 licenseToken = userData.USER?.OPTIONS?.license_token;

View 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
View 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
View 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();

View 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'
};
}
}

View 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 };
}

View 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
}
};
}

View 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();

View File

@@ -50,11 +50,34 @@ export async function loadDownloadQueue(): Promise<void> {
const queue = await store.get<Record<string, QueueItem>>('queue');
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 ?? [],
queue: queue ?? {},
currentJob: currentJob ?? null
});
queue: cleanedQueue,
currentJob: null // Always clear currentJob on load
};
downloadQueue.set(newState);
await saveQueue(newState);
}
// Save queue to disk

223
src/lib/stores/playback.ts Normal file
View 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
View 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
View 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
View 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;
}
});
}

View File

@@ -3,15 +3,39 @@
import TitleBar from "$lib/TitleBar.svelte";
import MenuBar from "$lib/MenuBar.svelte";
import ToolBar from "$lib/ToolBar.svelte";
import NowPlayingPanel from "$lib/components/NowPlayingPanel.svelte";
import { settings, loadSettings } from '$lib/stores/settings';
import { scanPlaylists, type Playlist } from '$lib/library/scanner';
import { downloadQueue } from '$lib/stores/downloadQueue';
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
import { playback } from '$lib/stores/playback';
import { status } from '$lib/stores/status';
let { children } = $props();
let playlists = $state<Playlist[]>([]);
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)
let activeDownloads = $derived(
@@ -85,7 +109,7 @@
<div class="app-container">
<TitleBar />
<MenuBar />
<ToolBar />
<ToolBar onToggleNowPlaying={toggleNowPlaying} />
<div class="main-layout">
<aside class="sidebar sunken-panel">
@@ -108,14 +132,18 @@
Services
</summary>
<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" />
Spotify
</a> -->
</a>
<a href="/services/deezer" class="nav-item nav-subitem">
<img src="/icons/deezer.png" alt="" class="nav-icon" />
Deezer
</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">
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
Soulseek
@@ -149,12 +177,20 @@
</nav>
</aside>
<div class="right-column">
<main class="content-area sunken-panel">
{@render children?.()}
</main>
{#if showNowPlaying}
<div class="banner-panel sunken-panel">
<NowPlayingPanel />
</div>
{/if}
</div>
</div>
<div class="status-text">Ready</div>
<div class="status-text">{$status}</div>
</div>
<style>
@@ -230,15 +266,32 @@
padding-left: 8px;
}
.content-area {
.right-column {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.content-area {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0;
font-family: "Pixelated MS Sans Serif", Arial;
background: #121212;
}
.banner-panel {
flex-shrink: 0;
height: 120px;
padding: 8px;
font-family: "Pixelated MS Sans Serif", Arial;
background: #121212;
overflow: hidden;
}
.status-text {
padding: 6px 8px 10px 10px;
font-family: "Pixelated MS Sans Serif", Arial;

View File

@@ -79,6 +79,7 @@
{selectedTrackIndex}
onTrackClick={handleTrackClick}
showAlbumColumn={false}
decorationLabel="ALBUM OVERVIEW"
/>
{/if}
</div>

View File

@@ -42,9 +42,24 @@
}
</script>
<div class="downloads-page">
<div class="header">
<h2>Downloads</h2>
<div class="downloads-wrapper">
<h2 style="padding: 8px">Downloads</h2>
<section class="downloads-content">
<!--
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={true}>
<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
@@ -58,7 +73,7 @@
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
</div>
{:else}
<div class="sunken-panel" style="overflow: auto; flex: 1;">
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
@@ -101,24 +116,58 @@
</div>
{/if}
</div>
</div>
</section>
</div>
<style>
.downloads-page {
.downloads-wrapper {
height: 100%;
display: flex;
flex-direction: column;
padding: 8px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
h2 {
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 {
@@ -126,13 +175,21 @@
gap: 8px;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty-state {
padding: 32px 16px;
text-align: center;
opacity: 0.6;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: light-dark(#666, #999);
}
.empty-state p {
@@ -155,6 +212,9 @@
.col-title {
width: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.col-artist {

View File

@@ -124,8 +124,11 @@
selectedArtistIndex = index;
}
function handleAlbumClick(album: Album, index: number) {
function handleAlbumClick(index: number) {
selectedAlbumIndex = index;
}
function handleAlbumDoubleClick(album: Album) {
const artistEncoded = encodeURIComponent(album.artist);
const albumEncoded = encodeURIComponent(album.title);
goto(`/albums/${artistEncoded}/${albumEncoded}`);
@@ -238,7 +241,8 @@
{#each albums as album, i}
<tr
class:highlighted={selectedAlbumIndex === i}
onclick={() => handleAlbumClick(album, i)}
onclick={() => handleAlbumClick(i)}
ondblclick={() => handleAlbumDoubleClick(album)}
>
<td class="cover-cell">
{#if album.coverArtPath}

View File

@@ -94,6 +94,7 @@
{selectedTrackIndex}
onTrackClick={handleTrackClick}
showAlbumColumn={true}
useSequentialNumbers={true}
/>
{/if}
</div>

View File

@@ -98,11 +98,15 @@
// If we have cached album picture, use it
if (cachedTracks[0].album_picture) {
playlistPicture = cachedTracks[0].album_picture;
} else if ($deezerAuth.arl && cachedTracks[0].track_id) {
} else if ($deezerAuth.arl) {
// Get the track ID - handle both DeezerTrack (has 'id') and DeezerPlaylistTrack (has 'track_id')
const trackId = 'track_id' in cachedTracks[0] ? cachedTracks[0].track_id : cachedTracks[0].id;
if (trackId) {
// Fetch album data from API to get cover
try {
deezerAPI.setArl($deezerAuth.arl);
const trackData = await deezerAPI.getTrackData(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;
@@ -111,7 +115,7 @@
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, cachedTracks[0].track_id]
[albumCoverUrl, trackId]
);
}
} catch (err) {
@@ -119,6 +123,7 @@
}
}
}
}
// Final fallback to local files
if ((!playlistPicture || !playlistPicture.startsWith('http')) && $settings.musicFolder) {
@@ -234,7 +239,6 @@
title: dbTrack.title,
artist: dbTrack.artist_name,
album: dbTrack.album_title || undefined,
trackNumber: dbTrack.track_number || undefined,
duration: dbTrack.duration
}
};

View 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>

View 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;
}

View 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>

View File

@@ -19,6 +19,8 @@
import { clearDeezerCache } from '$lib/library/deezer-database';
import { open, confirm, message } from '@tauri-apps/plugin-dialog';
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 currentPlaylistsFolder = $state<string | null>(null);
@@ -122,10 +124,27 @@
}
}
}
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>
<div style="padding: 8px;">
<h2>Settings</h2>
<div class="settings-wrapper">
<h2 style="padding: 8px">Settings</h2>
<section class="settings-content">
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
@@ -146,10 +165,10 @@
</li>
</menu>
<div class="window" role="tabpanel">
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if activeTab === 'library'}
<section class="tab-content">
<section>
<h3>Library Folders</h3>
<div class="field-row-stacked">
<label for="music-folder">Music Folder</label>
@@ -197,7 +216,7 @@
</div>
</section>
{:else if activeTab === 'deezer'}
<section class="tab-content">
<section>
<h3>Deezer Download Settings</h3>
<div class="field-row-stacked">
@@ -319,7 +338,7 @@
</fieldset>
</section>
{:else if activeTab === 'advanced'}
<section class="tab-content">
<section>
<h3>Advanced Settings</h3>
<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>
<button onclick={clearDeezerDatabase}>Clear Deezer Cache</button>
</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>
{/if}
</div>
</div>
</section>
</div>
<style>
.settings-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin-top: 0;
margin-bottom: 12px;
margin: 0;
}
h3 {
@@ -357,12 +388,27 @@
font-size: 1.1em;
}
menu[role="tablist"] {
margin-bottom: 0;
.settings-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.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 {

View 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
View 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

View 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
View 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

View File

@@ -1,3 +1,5 @@
<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="M10 20H8V4H10V6H12V9H14V11H16V13H14V15H12V18H10V20Z" fill="black"/>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" version="1.1" viewBox="0 0 24 24">
<!-- 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

View 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

View 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

View 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

View 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

View 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

View 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