mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
Compare commits
7 Commits
25ce2d676e
...
456f854863
| Author | SHA1 | Date | |
|---|---|---|---|
| 456f854863 | |||
| 3118d969c6 | |||
| 17b6f7958e | |||
| cba49ce411 | |||
| 369ea9df02 | |||
| ca5f79b23a | |||
| 8fb27b1acd |
3
bun.lock
3
bun.lock
@@ -10,6 +10,7 @@
|
|||||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-os": "~2",
|
||||||
"@tauri-apps/plugin-process": "~2",
|
"@tauri-apps/plugin-process": "~2",
|
||||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
@@ -187,6 +188,8 @@
|
|||||||
|
|
||||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="],
|
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="],
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ty5V8XDUIFbSnrk3zsFoP3kzN+vAufYzalJSlmrVhQTImIZa1aL1a03bOaP2vuBvfR+WDRC6NgV2xBl8G07d+w=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ=="],
|
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-sql": ["@tauri-apps/plugin-sql@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-JYwIocfsLaDWa41LMiZWuzts7yCJR+EpZPRmgpO7Gd7XiAS9S67dKz306j/k/d9XntB0YopMRBol2OIWMschuA=="],
|
"@tauri-apps/plugin-sql": ["@tauri-apps/plugin-sql@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-JYwIocfsLaDWa41LMiZWuzts7yCJR+EpZPRmgpO7Gd7XiAS9S67dKz306j/k/d9XntB0YopMRBol2OIWMschuA=="],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-os": "~2",
|
||||||
"@tauri-apps/plugin-process": "~2",
|
"@tauri-apps/plugin-process": "~2",
|
||||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
|
|||||||
50
src-tauri/Cargo.lock
generated
50
src-tauri/Cargo.lock
generated
@@ -21,6 +21,7 @@ dependencies = [
|
|||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-os",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-sql",
|
"tauri-plugin-sql",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
@@ -1471,6 +1472,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gethostname"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.1.16"
|
version = "0.1.16"
|
||||||
@@ -2969,6 +2980,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "os_info"
|
||||||
|
version = "3.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"plist",
|
||||||
|
"serde",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -4609,6 +4632,15 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sys-locale"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -4919,6 +4951,24 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-os"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba"
|
||||||
|
dependencies = [
|
||||||
|
"gethostname",
|
||||||
|
"log",
|
||||||
|
"os_info",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serialize-to-javascript",
|
||||||
|
"sys-locale",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-process"
|
name = "tauri-plugin-process"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
|
|||||||
@@ -36,4 +36,5 @@ byteorder = "1.5.0"
|
|||||||
reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
|
reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
|
||||||
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
|
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,20 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-path",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"path": "$APPDATA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$APPCONFIG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$APPLOCALDATA"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
@@ -63,6 +77,7 @@
|
|||||||
},
|
},
|
||||||
"sql:default",
|
"sql:default",
|
||||||
"sql:allow-execute",
|
"sql:allow-execute",
|
||||||
"process:default"
|
"process:default",
|
||||||
|
"os:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -23,8 +23,7 @@ pub fn generate_blowfish_key(track_id: &str) -> Vec<u8> {
|
|||||||
|
|
||||||
/// Decrypt a single 2048-byte chunk using Blowfish CBC
|
/// Decrypt a single 2048-byte chunk using Blowfish CBC
|
||||||
pub fn decrypt_chunk(chunk: &[u8], blowfish_key: &[u8]) -> Vec<u8> {
|
pub fn decrypt_chunk(chunk: &[u8], blowfish_key: &[u8]) -> Vec<u8> {
|
||||||
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key)
|
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key).expect("Invalid key length");
|
||||||
.expect("Invalid key length");
|
|
||||||
|
|
||||||
let mut result = chunk.to_vec();
|
let mut result = chunk.to_vec();
|
||||||
let iv = [0u8, 1, 2, 3, 4, 5, 6, 7];
|
let iv = [0u8, 1, 2, 3, 4, 5, 6, 7];
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
|
|
||||||
mod tagger;
|
|
||||||
mod metadata;
|
|
||||||
mod deezer_crypto;
|
mod deezer_crypto;
|
||||||
|
mod metadata;
|
||||||
|
mod tagger;
|
||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -58,10 +58,10 @@ async fn download_and_decrypt_track(
|
|||||||
is_encrypted: bool,
|
is_encrypted: bool,
|
||||||
window: tauri::Window,
|
window: tauri::Window,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use tokio::fs::File;
|
|
||||||
use deezer_crypto::StreamingDecryptor;
|
use deezer_crypto::StreamingDecryptor;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
// Build HTTP client
|
// Build HTTP client
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -117,11 +117,14 @@ async fn download_and_decrypt_track(
|
|||||||
|
|
||||||
if rounded_percentage > last_reported_percentage || percentage == 100 {
|
if rounded_percentage > last_reported_percentage || percentage == 100 {
|
||||||
last_reported_percentage = rounded_percentage;
|
last_reported_percentage = rounded_percentage;
|
||||||
let _ = window.emit("download-progress", serde_json::json!({
|
let _ = window.emit(
|
||||||
|
"download-progress",
|
||||||
|
serde_json::json!({
|
||||||
"downloaded": downloaded_bytes,
|
"downloaded": downloaded_bytes,
|
||||||
"total": total_size as u64,
|
"total": total_size as u64,
|
||||||
"percentage": percentage
|
"percentage": percentage
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,11 +157,14 @@ async fn download_and_decrypt_track(
|
|||||||
|
|
||||||
if rounded_percentage > last_reported_percentage || percentage == 100 {
|
if rounded_percentage > last_reported_percentage || percentage == 100 {
|
||||||
last_reported_percentage = rounded_percentage;
|
last_reported_percentage = rounded_percentage;
|
||||||
let _ = window.emit("download-progress", serde_json::json!({
|
let _ = window.emit(
|
||||||
|
"download-progress",
|
||||||
|
serde_json::json!({
|
||||||
"downloaded": downloaded_bytes,
|
"downloaded": downloaded_bytes,
|
||||||
"total": total_size as u64,
|
"total": total_size as u64,
|
||||||
"percentage": percentage
|
"percentage": percentage
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,10 +209,25 @@ pub fn run() {
|
|||||||
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE
|
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tracks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
album TEXT NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
has_lyrics INTEGER DEFAULT 0,
|
||||||
|
last_scanned INTEGER,
|
||||||
|
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name);
|
CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id);
|
CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
|
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
|
||||||
CREATE INDEX IF NOT EXISTS idx_albums_artist_title ON albums(artist_name, title);
|
CREATE INDEX IF NOT EXISTS idx_albums_artist_title ON albums(artist_name, title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tracks_has_lyrics ON tracks(has_lyrics);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tracks_path ON tracks(path);
|
||||||
",
|
",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
}];
|
}];
|
||||||
@@ -278,6 +299,7 @@ pub fn run() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_sql::Builder::new()
|
tauri_plugin_sql::Builder::new()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use metaflac::Tag as FlacTag;
|
|
||||||
use id3::{Tag as ID3Tag, TagLike};
|
use id3::{Tag as ID3Tag, TagLike};
|
||||||
|
use metaflac::Tag as FlacTag;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ pub fn read_audio_metadata(path: &str) -> Result<AudioMetadata, String> {
|
|||||||
|
|
||||||
/// Read metadata from MP3 file
|
/// Read metadata from MP3 file
|
||||||
fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
|
fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||||
let tag = ID3Tag::read_from_path(path)
|
let tag =
|
||||||
.map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
|
ID3Tag::read_from_path(path).map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
|
||||||
|
|
||||||
Ok(AudioMetadata {
|
Ok(AudioMetadata {
|
||||||
title: tag.title().map(|s| s.to_string()),
|
title: tag.title().map(|s| s.to_string()),
|
||||||
@@ -55,8 +55,8 @@ fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
|
|||||||
|
|
||||||
/// Read metadata from FLAC file
|
/// Read metadata from FLAC file
|
||||||
fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
|
fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||||
let tag = FlacTag::read_from_path(path)
|
let tag =
|
||||||
.map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
|
FlacTag::read_from_path(path).map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
|
||||||
|
|
||||||
// Helper to get first value from vorbis comment
|
// Helper to get first value from vorbis comment
|
||||||
let get_first = |key: &str| -> Option<String> {
|
let get_first = |key: &str| -> Option<String> {
|
||||||
@@ -66,8 +66,7 @@ fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parse track number
|
// Parse track number
|
||||||
let track_number = get_first("TRACKNUMBER")
|
let track_number = get_first("TRACKNUMBER").and_then(|s| s.parse::<u32>().ok());
|
||||||
.and_then(|s| s.parse::<u32>().ok());
|
|
||||||
|
|
||||||
// Get duration from streaminfo block (in samples)
|
// Get duration from streaminfo block (in samples)
|
||||||
let duration = tag.get_streaminfo().map(|info| {
|
let duration = tag.get_streaminfo().map(|info| {
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ export interface DbAlbum {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DbTrack {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
format: string;
|
||||||
|
has_lyrics: number;
|
||||||
|
last_scanned: number | null;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
let db: Database | null = null;
|
let db: Database | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,3 +244,82 @@ export async function getLibraryStats(): Promise<{
|
|||||||
trackCount: trackResult[0]?.total || 0
|
trackCount: trackResult[0]?.total || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tracks without lyrics (has_lyrics = 0)
|
||||||
|
*/
|
||||||
|
export async function getTracksWithoutLyrics(): Promise<DbTrack[]> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const tracks = await database.select<DbTrack[]>(
|
||||||
|
'SELECT * FROM tracks WHERE has_lyrics = 0 ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE'
|
||||||
|
);
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a track (insert or update)
|
||||||
|
*/
|
||||||
|
export async function upsertTrack(track: {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
format: string;
|
||||||
|
has_lyrics: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
await database.execute(
|
||||||
|
`INSERT INTO tracks (path, title, artist, album, duration, format, has_lyrics, last_scanned)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
|
title = $2,
|
||||||
|
artist = $3,
|
||||||
|
album = $4,
|
||||||
|
duration = $5,
|
||||||
|
format = $6,
|
||||||
|
has_lyrics = $7,
|
||||||
|
last_scanned = $8`,
|
||||||
|
[
|
||||||
|
track.path,
|
||||||
|
track.title,
|
||||||
|
track.artist,
|
||||||
|
track.album,
|
||||||
|
track.duration,
|
||||||
|
track.format,
|
||||||
|
track.has_lyrics ? 1 : 0,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last scan timestamp for lyrics
|
||||||
|
*/
|
||||||
|
export async function getLyricsScanTimestamp(): Promise<number | null> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const result = await database.select<{ last_scanned: number | null }[]>(
|
||||||
|
'SELECT MAX(last_scanned) as last_scanned FROM tracks'
|
||||||
|
);
|
||||||
|
return result[0]?.last_scanned || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete tracks that are no longer in the provided paths
|
||||||
|
*/
|
||||||
|
export async function deleteTracksNotInPaths(paths: string[]): Promise<void> {
|
||||||
|
if (paths.length === 0) {
|
||||||
|
const database = await initDatabase();
|
||||||
|
await database.execute('DELETE FROM tracks');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await initDatabase();
|
||||||
|
const placeholders = paths.map((_, i) => `$${i + 1}`).join(',');
|
||||||
|
await database.execute(
|
||||||
|
`DELETE FROM tracks WHERE path NOT IN (${placeholders})`,
|
||||||
|
paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { readDir, exists, readFile } from '@tauri-apps/plugin-fs';
|
import { readDir, exists, readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import type { AudioFormat } from '$lib/types/track';
|
import type { AudioFormat } from '$lib/types/track';
|
||||||
|
import { upsertTrack, getTracksWithoutLyrics, type DbTrack } from '$lib/library/database';
|
||||||
|
|
||||||
export interface TrackWithoutLyrics {
|
export interface TrackWithoutLyrics {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -116,6 +117,7 @@ async function scanDirectoryForMissingLyrics(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan the music library for tracks without .lrc files
|
* Scan the music library for tracks without .lrc files
|
||||||
|
* Results are cached in the database
|
||||||
*/
|
*/
|
||||||
export async function scanForTracksWithoutLyrics(
|
export async function scanForTracksWithoutLyrics(
|
||||||
musicFolderPath: string,
|
musicFolderPath: string,
|
||||||
@@ -129,9 +131,43 @@ export async function scanForTracksWithoutLyrics(
|
|||||||
|
|
||||||
await scanDirectoryForMissingLyrics(musicFolderPath, results);
|
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) {
|
if (onProgress) {
|
||||||
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
|
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cached tracks without lyrics from database
|
||||||
|
*/
|
||||||
|
export async function loadCachedTracksWithoutLyrics(): Promise<TrackWithoutLyrics[]> {
|
||||||
|
const dbTracks = await getTracksWithoutLyrics();
|
||||||
|
|
||||||
|
return dbTracks.map((track: DbTrack) => ({
|
||||||
|
path: track.path,
|
||||||
|
filename: track.path.split('/').pop() || track.path,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.album,
|
||||||
|
duration: track.duration,
|
||||||
|
format: track.format as AudioFormat
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||||
|
import { encodeEmojis } from '$lib/utils/emoji';
|
||||||
|
|
||||||
export interface M3U8Track {
|
export interface M3U8Track {
|
||||||
duration: number; // in seconds
|
duration: number; // in seconds
|
||||||
@@ -22,14 +23,15 @@ export async function writeM3U8(
|
|||||||
tracks: M3U8Track[],
|
tracks: M3U8Track[],
|
||||||
playlistsFolder: string
|
playlistsFolder: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Sanitize playlist name for filename
|
// Encode emojis and sanitize playlist name for filename
|
||||||
const sanitizedName = sanitizeFilename(playlistName);
|
const encodedName = encodeEmojis(playlistName);
|
||||||
|
const sanitizedName = sanitizeFilename(encodedName);
|
||||||
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
|
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
|
||||||
|
|
||||||
// Build m3u8 content
|
// Build m3u8 content
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'#EXTM3U',
|
'#EXTM3U',
|
||||||
`#PLAYLIST:${playlistName}`,
|
`#PLAYLIST:${encodedName}`,
|
||||||
'#EXTENC:UTF-8',
|
'#EXTENC:UTF-8',
|
||||||
''
|
''
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
||||||
import { findAlbumArt } from './album';
|
import { findAlbumArt } from './album';
|
||||||
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||||
|
import { decodeEmojis } from '$lib/utils/emoji';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get audio format from file extension
|
* Get audio format from file extension
|
||||||
@@ -36,6 +37,30 @@ export interface ParsedPlaylistTrack {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract playlist name from #PLAYLIST: metadata line in m3u8 file
|
||||||
|
* Returns decoded emoji name, or undefined if not found
|
||||||
|
*/
|
||||||
|
export async function parsePlaylistName(playlistPath: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await readTextFile(playlistPath);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('#PLAYLIST:')) {
|
||||||
|
const encodedName = trimmed.substring('#PLAYLIST:'.length);
|
||||||
|
return decodeEmojis(encodedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading playlist name:', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse M3U/M3U8 playlist file
|
* Parse M3U/M3U8 playlist file
|
||||||
* Supports both basic M3U and extended M3U8 format
|
* Supports both basic M3U and extended M3U8 format
|
||||||
@@ -52,7 +77,7 @@ export async function parsePlaylist(playlistPath: string): Promise<ParsedPlaylis
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
|
|
||||||
// Skip empty lines and non-EXTINF comments
|
// Skip empty lines and comments (except EXTINF)
|
||||||
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
|
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readDir, readFile } from '@tauri-apps/plugin-fs';
|
import { readDir, readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
|
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
|
||||||
|
import { parsePlaylistName } from './playlist';
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -283,11 +284,18 @@ export async function scanPlaylists(playlistsFolderPath: string): Promise<Playli
|
|||||||
if (!entry.isDirectory) {
|
if (!entry.isDirectory) {
|
||||||
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
|
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
|
||||||
if (isPlaylist) {
|
if (isPlaylist) {
|
||||||
// Remove extension for display name
|
const playlistPath = `${playlistsFolderPath}/${entry.name}`;
|
||||||
|
|
||||||
|
// Try to read playlist name from #PLAYLIST: metadata (with emoji decoding)
|
||||||
|
const metadataName = await parsePlaylistName(playlistPath);
|
||||||
|
|
||||||
|
// Fallback to filename without extension if no metadata found
|
||||||
const nameWithoutExt = entry.name.replace(/\.(m3u8?|M3U8?)$/, '');
|
const nameWithoutExt = entry.name.replace(/\.(m3u8?|M3U8?)$/, '');
|
||||||
|
const displayName = metadataName || nameWithoutExt;
|
||||||
|
|
||||||
playlists.push({
|
playlists.push({
|
||||||
name: nameWithoutExt,
|
name: displayName,
|
||||||
path: `${playlistsFolderPath}/${entry.name}`
|
path: playlistPath
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,9 +122,6 @@ export class DeezerQueueManager {
|
|||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
console.log('[DeezerQueueManager] Starting queue processor');
|
console.log('[DeezerQueueManager] Starting queue processor');
|
||||||
|
|
||||||
// Clear any stale currentJob from previous session
|
|
||||||
await setCurrentJob(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.processQueue();
|
await this.processQueue();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -50,11 +50,34 @@ export async function loadDownloadQueue(): Promise<void> {
|
|||||||
const queue = await store.get<Record<string, QueueItem>>('queue');
|
const queue = await store.get<Record<string, QueueItem>>('queue');
|
||||||
const currentJob = await store.get<string>('currentJob');
|
const currentJob = await store.get<string>('currentJob');
|
||||||
|
|
||||||
downloadQueue.set({
|
// Reset any items stuck in 'downloading' state from previous session
|
||||||
|
const cleanedQueue = { ...(queue ?? {}) };
|
||||||
|
let resetCount = 0;
|
||||||
|
for (const id in cleanedQueue) {
|
||||||
|
const item = cleanedQueue[id];
|
||||||
|
if (item && item.status === 'downloading') {
|
||||||
|
cleanedQueue[id] = {
|
||||||
|
...item,
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
currentTrack: undefined
|
||||||
|
};
|
||||||
|
resetCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetCount > 0) {
|
||||||
|
console.log(`[DownloadQueue] Reset ${resetCount} interrupted download(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = {
|
||||||
queueOrder: queueOrder ?? [],
|
queueOrder: queueOrder ?? [],
|
||||||
queue: queue ?? {},
|
queue: cleanedQueue,
|
||||||
currentJob: currentJob ?? null
|
currentJob: null // Always clear currentJob on load
|
||||||
});
|
};
|
||||||
|
|
||||||
|
downloadQueue.set(newState);
|
||||||
|
await saveQueue(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save queue to disk
|
// Save queue to disk
|
||||||
|
|||||||
82
src/lib/utils/emoji.ts
Normal file
82
src/lib/utils/emoji.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Emoji encoding/decoding utilities for filesystem-safe names
|
||||||
|
* Converts emojis to [U+XXXXX] format for safe storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a character is an emoji
|
||||||
|
* Emojis are in Unicode ranges:
|
||||||
|
* - Basic Emoticons: U+1F600 - U+1F64F
|
||||||
|
* - Dingbats: U+2700 - U+27BF
|
||||||
|
* - Miscellaneous Symbols: U+2600 - U+26FF
|
||||||
|
* - Transport and Map: U+1F680 - U+1F6FF
|
||||||
|
* - Supplemental Symbols: U+1F900 - U+1F9FF
|
||||||
|
* - Flags: U+1F1E6 - U+1F1FF
|
||||||
|
* - And many more...
|
||||||
|
*/
|
||||||
|
function isEmoji(codePoint: number): boolean {
|
||||||
|
return (
|
||||||
|
(codePoint >= 0x1F600 && codePoint <= 0x1F64F) || // Emoticons
|
||||||
|
(codePoint >= 0x1F300 && codePoint <= 0x1F5FF) || // Misc Symbols and Pictographs
|
||||||
|
(codePoint >= 0x1F680 && codePoint <= 0x1F6FF) || // Transport and Map
|
||||||
|
(codePoint >= 0x1F900 && codePoint <= 0x1F9FF) || // Supplemental Symbols
|
||||||
|
(codePoint >= 0x1F1E6 && codePoint <= 0x1F1FF) || // Flags
|
||||||
|
(codePoint >= 0x2600 && codePoint <= 0x26FF) || // Misc symbols
|
||||||
|
(codePoint >= 0x2700 && codePoint <= 0x27BF) || // Dingbats
|
||||||
|
(codePoint >= 0xFE00 && codePoint <= 0xFE0F) || // Variation Selectors
|
||||||
|
(codePoint >= 0x1F000 && codePoint <= 0x1F02F) || // Mahjong Tiles
|
||||||
|
(codePoint >= 0x1F0A0 && codePoint <= 0x1F0FF) || // Playing Cards
|
||||||
|
(codePoint >= 0x1FA70 && codePoint <= 0x1FAFF) || // Symbols and Pictographs Extended-A
|
||||||
|
(codePoint >= 0x200D) || // Zero Width Joiner (used in emoji sequences)
|
||||||
|
(codePoint >= 0x231A && codePoint <= 0x231B) || // Watch, Hourglass
|
||||||
|
(codePoint >= 0x23E9 && codePoint <= 0x23F3) || // Media controls
|
||||||
|
(codePoint >= 0x25AA && codePoint <= 0x25AB) || // Geometric shapes
|
||||||
|
(codePoint >= 0x25B6) || // Play button
|
||||||
|
(codePoint >= 0x2934 && codePoint <= 0x2935) || // Arrows
|
||||||
|
(codePoint >= 0x2B05 && codePoint <= 0x2B07) || // Arrows
|
||||||
|
(codePoint >= 0x3030) || // Wavy dash
|
||||||
|
(codePoint >= 0x303D) || // Part alternation mark
|
||||||
|
(codePoint >= 0x3297) || // Japanese symbols
|
||||||
|
(codePoint >= 0x3299) // Japanese symbols
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode emojis in text to [U+XXXXX] format
|
||||||
|
* Example: "hello 👀" → "hello [U+1F440]"
|
||||||
|
*/
|
||||||
|
export function encodeEmojis(text: string): string {
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
// Iterate through Unicode code points (not just chars, to handle surrogate pairs)
|
||||||
|
for (const char of text) {
|
||||||
|
const codePoint = char.codePointAt(0);
|
||||||
|
|
||||||
|
if (codePoint !== undefined && isEmoji(codePoint)) {
|
||||||
|
// Convert to hex string with uppercase
|
||||||
|
const hex = codePoint.toString(16).toUpperCase();
|
||||||
|
result += `[U+${hex}]`;
|
||||||
|
} else {
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode [U+XXXXX] format back to emojis
|
||||||
|
* Example: "hello [U+1F440]" → "hello 👀"
|
||||||
|
*/
|
||||||
|
export function decodeEmojis(text: string): string {
|
||||||
|
// Match [U+XXXXX] patterns (hex can be 4-6 digits for Unicode)
|
||||||
|
return text.replace(/\[U\+([0-9A-Fa-f]+)\]/g, (match, hex) => {
|
||||||
|
try {
|
||||||
|
const codePoint = parseInt(hex, 16);
|
||||||
|
return String.fromCodePoint(codePoint);
|
||||||
|
} catch {
|
||||||
|
// If parsing fails, return the original match
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -42,9 +42,24 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="downloads-page">
|
<div class="downloads-wrapper">
|
||||||
<div class="header">
|
<h2 style="padding: 8px">Downloads</h2>
|
||||||
<h2>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">
|
<div class="header-actions">
|
||||||
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
|
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
|
||||||
Clear Completed
|
Clear Completed
|
||||||
@@ -58,7 +73,7 @@
|
|||||||
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
|
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="sunken-panel" style="overflow: auto; flex: 1;">
|
<div class="sunken-panel table-container">
|
||||||
<table class="interactive">
|
<table class="interactive">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -100,25 +115,59 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.downloads-page {
|
.downloads-wrapper {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.4em;
|
}
|
||||||
|
|
||||||
|
.downloads-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content .window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--button-shadow, #808080);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -126,13 +175,21 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.6;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: light-dark(#666, #999);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
@@ -155,6 +212,9 @@
|
|||||||
|
|
||||||
.col-title {
|
.col-title {
|
||||||
width: auto;
|
width: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-artist {
|
.col-artist {
|
||||||
|
|||||||
@@ -124,8 +124,11 @@
|
|||||||
selectedArtistIndex = index;
|
selectedArtistIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAlbumClick(album: Album, index: number) {
|
function handleAlbumClick(index: number) {
|
||||||
selectedAlbumIndex = index;
|
selectedAlbumIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlbumDoubleClick(album: Album) {
|
||||||
const artistEncoded = encodeURIComponent(album.artist);
|
const artistEncoded = encodeURIComponent(album.artist);
|
||||||
const albumEncoded = encodeURIComponent(album.title);
|
const albumEncoded = encodeURIComponent(album.title);
|
||||||
goto(`/albums/${artistEncoded}/${albumEncoded}`);
|
goto(`/albums/${artistEncoded}/${albumEncoded}`);
|
||||||
@@ -238,7 +241,8 @@
|
|||||||
{#each albums as album, i}
|
{#each albums as album, i}
|
||||||
<tr
|
<tr
|
||||||
class:highlighted={selectedAlbumIndex === i}
|
class:highlighted={selectedAlbumIndex === i}
|
||||||
onclick={() => handleAlbumClick(album, i)}
|
onclick={() => handleAlbumClick(i)}
|
||||||
|
ondblclick={() => handleAlbumDoubleClick(album)}
|
||||||
>
|
>
|
||||||
<td class="cover-cell">
|
<td class="cover-cell">
|
||||||
{#if album.coverArtPath}
|
{#if album.coverArtPath}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
import { setSuccess, setWarning, setError, setInfo } from '$lib/stores/status';
|
import { setSuccess, setWarning, setError, setInfo, removeStatus } from '$lib/stores/status';
|
||||||
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
|
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
|
||||||
import { scanForTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner';
|
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';
|
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
|
||||||
|
|
||||||
type ViewMode = 'tracks' | 'info';
|
type ViewMode = 'tracks' | 'info';
|
||||||
@@ -16,11 +17,22 @@
|
|||||||
let tracks = $state<TrackWithoutLyrics[]>([]);
|
let tracks = $state<TrackWithoutLyrics[]>([]);
|
||||||
let selectedTrackIndex = $state<number | null>(null);
|
let selectedTrackIndex = $state<number | null>(null);
|
||||||
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
|
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
|
||||||
|
let lastScanned = $state<number | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await checkApi();
|
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() {
|
async function checkApi() {
|
||||||
checkingApi = true;
|
checkingApi = true;
|
||||||
apiAvailable = await checkApiStatus();
|
apiAvailable = await checkApiStatus();
|
||||||
@@ -45,6 +57,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
tracks = foundTracks;
|
tracks = foundTracks;
|
||||||
|
lastScanned = await getLyricsScanTimestamp();
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
setInfo('All tracks have lyrics!');
|
setInfo('All tracks have lyrics!');
|
||||||
@@ -72,6 +85,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
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) {
|
if (result.instrumental) {
|
||||||
setInfo(`Track marked as instrumental: ${track.title}`);
|
setInfo(`Track marked as instrumental: ${track.title}`);
|
||||||
} else if (result.hasLyrics) {
|
} else if (result.hasLyrics) {
|
||||||
@@ -92,13 +116,16 @@
|
|||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
const totalTracks = tracks.length;
|
||||||
|
|
||||||
setInfo(`Fetching lyrics for ${tracks.length} tracks...`, 0);
|
// Create a single status message that we'll update
|
||||||
|
const statusId = setInfo(`Fetching lyrics... 0/${totalTracks}`, 0);
|
||||||
|
|
||||||
const tracksCopy = [...tracks];
|
// Process tracks one by one, removing from array as we go
|
||||||
|
let processedCount = 0;
|
||||||
for (let i = 0; i < tracksCopy.length; i++) {
|
while (tracks.length > 0) {
|
||||||
const track = tracksCopy[i];
|
const track = tracks[0]; // Always process first track
|
||||||
|
processedCount++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchAndSaveLyrics(track.path, {
|
const result = await fetchAndSaveLyrics(track.path, {
|
||||||
@@ -109,23 +136,37 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success && (result.hasLyrics || result.instrumental)) {
|
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++;
|
successCount++;
|
||||||
|
// Remove from UI immediately on success
|
||||||
|
tracks = tracks.slice(1);
|
||||||
} else {
|
} else {
|
||||||
failCount++;
|
failCount++;
|
||||||
|
// Remove from list even if no lyrics found
|
||||||
|
tracks = tracks.slice(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failCount++;
|
failCount++;
|
||||||
|
// Remove from list on error
|
||||||
|
tracks = tracks.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress
|
// Update progress message
|
||||||
if ((i + 1) % 10 === 0 || i === tracksCopy.length - 1) {
|
setInfo(`Fetching lyrics... ${processedCount}/${totalTracks}`, 0);
|
||||||
setInfo(`Fetching lyrics... ${i + 1}/${tracksCopy.length}`, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rescan to update the list
|
// Remove the progress message
|
||||||
tracks = [];
|
removeStatus(statusId);
|
||||||
await handleScan();
|
|
||||||
|
|
||||||
// Show completion message
|
// Show completion message
|
||||||
if (successCount > 0 && failCount > 0) {
|
if (successCount > 0 && failCount > 0) {
|
||||||
@@ -194,7 +235,12 @@
|
|||||||
{#if viewMode === 'tracks'}
|
{#if viewMode === 'tracks'}
|
||||||
<!-- Tracks View -->
|
<!-- Tracks View -->
|
||||||
<div class="tab-header">
|
<div class="tab-header">
|
||||||
|
<div class="header-left">
|
||||||
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
|
<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">
|
<div class="actions-row">
|
||||||
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
|
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
|
||||||
{scanning ? 'Scanning...' : 'Scan Library'}
|
{scanning ? 'Scanning...' : 'Scan Library'}
|
||||||
@@ -337,10 +383,15 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-row {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-scanned {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
import { clearDeezerCache } from '$lib/library/deezer-database';
|
import { clearDeezerCache } from '$lib/library/deezer-database';
|
||||||
import { open, confirm, message } from '@tauri-apps/plugin-dialog';
|
import { open, confirm, message } from '@tauri-apps/plugin-dialog';
|
||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
|
import { appDataDir } from '@tauri-apps/api/path';
|
||||||
|
import { openPath } from '@tauri-apps/plugin-opener';
|
||||||
|
|
||||||
let currentMusicFolder = $state<string | null>(null);
|
let currentMusicFolder = $state<string | null>(null);
|
||||||
let currentPlaylistsFolder = $state<string | null>(null);
|
let currentPlaylistsFolder = $state<string | null>(null);
|
||||||
@@ -122,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>
|
</script>
|
||||||
|
|
||||||
<div style="padding: 8px;">
|
<div class="settings-wrapper">
|
||||||
<h2>Settings</h2>
|
<h2 style="padding: 8px">Settings</h2>
|
||||||
|
|
||||||
|
<section class="settings-content">
|
||||||
<!--
|
<!--
|
||||||
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
@@ -146,10 +165,10 @@
|
|||||||
</li>
|
</li>
|
||||||
</menu>
|
</menu>
|
||||||
|
|
||||||
<div class="window" role="tabpanel">
|
<div class="window tab-content" role="tabpanel">
|
||||||
<div class="window-body">
|
<div class="window-body">
|
||||||
{#if activeTab === 'library'}
|
{#if activeTab === 'library'}
|
||||||
<section class="tab-content">
|
<section>
|
||||||
<h3>Library Folders</h3>
|
<h3>Library Folders</h3>
|
||||||
<div class="field-row-stacked">
|
<div class="field-row-stacked">
|
||||||
<label for="music-folder">Music Folder</label>
|
<label for="music-folder">Music Folder</label>
|
||||||
@@ -197,7 +216,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if activeTab === 'deezer'}
|
{:else if activeTab === 'deezer'}
|
||||||
<section class="tab-content">
|
<section>
|
||||||
<h3>Deezer Download Settings</h3>
|
<h3>Deezer Download Settings</h3>
|
||||||
|
|
||||||
<div class="field-row-stacked">
|
<div class="field-row-stacked">
|
||||||
@@ -319,7 +338,7 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
{:else if activeTab === 'advanced'}
|
{:else if activeTab === 'advanced'}
|
||||||
<section class="tab-content">
|
<section>
|
||||||
<h3>Advanced Settings</h3>
|
<h3>Advanced Settings</h3>
|
||||||
|
|
||||||
<div class="field-row-stacked">
|
<div class="field-row-stacked">
|
||||||
@@ -339,16 +358,28 @@
|
|||||||
<small class="help-text">This will delete all cached Deezer favorites data. The next time you visit the Deezer page, it will refetch from the API.</small>
|
<small class="help-text">This will delete all cached Deezer favorites data. The next time you visit the Deezer page, it will refetch from the API.</small>
|
||||||
<button onclick={clearDeezerDatabase}>Clear Deezer Cache</button>
|
<button onclick={clearDeezerDatabase}>Clear Deezer Cache</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<div class="setting-heading">Open App Data Folder</div>
|
||||||
|
<small class="help-text">Opens the application data folder containing SQLite databases and other app data.</small>
|
||||||
|
<button onclick={openAppDataFolder}>Open Folder</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.settings-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-top: 0;
|
margin: 0;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -357,12 +388,27 @@
|
|||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
menu[role="tablist"] {
|
.settings-content {
|
||||||
margin-bottom: 0;
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
margin: 0;
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content .window-body {
|
||||||
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-note {
|
.info-note {
|
||||||
|
|||||||
Reference in New Issue
Block a user