Compare commits

...

7 Commits

20 changed files with 636 additions and 120 deletions

View File

@@ -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=="],

View File

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

@@ -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"

View File

@@ -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"

View File

@@ -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"
] ]
} }

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 /// 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];

View File

@@ -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()

View File

@@ -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| {

View File

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

View File

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

View File

@@ -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',
'' ''
]; ];

View File

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

View File

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

View File

@@ -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) {

View File

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

@@ -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 {

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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 {