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

@@ -20,6 +20,7 @@
"@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",

50
src-tauri/Cargo.lock generated
View File

@@ -21,6 +21,7 @@ dependencies = [
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-sql",
"tauri-plugin-store",
@@ -1471,6 +1472,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"
@@ -2969,6 +2980,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"
@@ -4609,6 +4632,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"
@@ -4919,6 +4951,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"

View File

@@ -36,4 +36,5 @@ 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"

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",
@@ -63,6 +77,7 @@
},
"sql:default",
"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
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];

View File

@@ -1,8 +1,8 @@
use tauri_plugin_sql::{Migration, MigrationKind};
mod tagger;
mod metadata;
mod deezer_crypto;
mod metadata;
mod tagger;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
@@ -58,10 +58,10 @@ async fn download_and_decrypt_track(
is_encrypted: bool,
window: tauri::Window,
) -> Result<(), String> {
use tokio::io::AsyncWriteExt;
use tokio::fs::File;
use deezer_crypto::StreamingDecryptor;
use tauri::Emitter;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
// Build HTTP client
let client = reqwest::Client::builder()
@@ -117,11 +117,14 @@ async fn download_and_decrypt_track(
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
}));
let _ = window.emit(
"download-progress",
serde_json::json!({
"downloaded": downloaded_bytes,
"total": total_size as u64,
"percentage": percentage
}),
);
}
}
}
@@ -154,11 +157,14 @@ async fn download_and_decrypt_track(
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
}));
let _ = window.emit(
"download-progress",
serde_json::json!({
"downloaded": downloaded_bytes,
"total": total_size as u64,
"percentage": percentage
}),
);
}
}
}
@@ -203,10 +209,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,
}];
@@ -278,6 +299,7 @@ pub fn run() {
}];
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init())
.plugin(
tauri_plugin_sql::Builder::new()

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

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

@@ -5,6 +5,7 @@
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;
@@ -116,6 +117,7 @@ async function scanDirectoryForMissingLyrics(
/**
* Scan the music library for tracks without .lrc files
* Results are cached in the database
*/
export async function scanForTracksWithoutLyrics(
musicFolderPath: string,
@@ -129,9 +131,43 @@ export async function scanForTracksWithoutLyrics(
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

@@ -3,6 +3,7 @@ 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
@@ -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
* 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++) {
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;
}

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

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

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

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,24 +42,39 @@
}
</script>
<div class="downloads-page">
<div class="header">
<h2>Downloads</h2>
<div class="header-actions">
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
Clear Completed
</button>
</div>
</div>
<div class="downloads-wrapper">
<h2 style="padding: 8px">Downloads</h2>
{#if queueItems.length === 0}
<div class="empty-state">
<p>No downloads in queue</p>
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
</div>
{:else}
<div class="sunken-panel" style="overflow: auto; flex: 1;">
<table class="interactive">
<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
</button>
</div>
</div>
{#if queueItems.length === 0}
<div class="empty-state">
<p>No downloads in queue</p>
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
</div>
{:else}
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th class="col-title">Title</th>
@@ -97,28 +112,62 @@
</tr>
{/each}
</tbody>
</table>
</table>
</div>
{/if}
</div>
</div>
{/if}
</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

@@ -1,9 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
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 { 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';
type ViewMode = 'tracks' | 'info';
@@ -16,11 +17,22 @@
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();
@@ -45,6 +57,7 @@
);
tracks = foundTracks;
lastScanned = await getLyricsScanTimestamp();
if (tracks.length === 0) {
setInfo('All tracks have lyrics!');
@@ -72,6 +85,17 @@
});
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) {
@@ -92,13 +116,16 @@
let successCount = 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];
for (let i = 0; i < tracksCopy.length; i++) {
const track = tracksCopy[i];
// 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, {
@@ -109,23 +136,37 @@
});
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
if ((i + 1) % 10 === 0 || i === tracksCopy.length - 1) {
setInfo(`Fetching lyrics... ${i + 1}/${tracksCopy.length}`, 0);
}
// Update progress message
setInfo(`Fetching lyrics... ${processedCount}/${totalTracks}`, 0);
}
// Rescan to update the list
tracks = [];
await handleScan();
// Remove the progress message
removeStatus(statusId);
// Show completion message
if (successCount > 0 && failCount > 0) {
@@ -194,7 +235,12 @@
{#if viewMode === 'tracks'}
<!-- Tracks View -->
<div class="tab-header">
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
<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'}
@@ -337,10 +383,15 @@
flex-shrink: 0;
}
.status-row {
.header-left {
display: flex;
align-items: center;
gap: 12px;
flex-direction: column;
gap: 4px;
}
.last-scanned {
font-size: 10px;
opacity: 0.6;
}
.status-indicator {

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,34 +124,51 @@
}
}
}
async function openAppDataFolder() {
try {
const dataPath = await appDataDir();
console.log('App data path:', dataPath);
if (!dataPath) {
throw new Error('Could not get app data directory path');
}
await openPath(dataPath);
} catch (error) {
console.error('Error opening app data folder:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
await message('Error opening app data folder: ' + errorMessage, { title: 'Error', kind: 'error' });
}
}
</script>
<div style="padding: 8px;">
<h2>Settings</h2>
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
The <menu> element IS interactive (contains clickable <button> elements) and the
role="tablist" properly describes the semantic purpose to assistive technology.
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
-->
<menu role="tablist">
<li role="tab" aria-selected={activeTab === 'library'}>
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
</li>
<li role="tab" aria-selected={activeTab === 'deezer'}>
<a href="#deezer" onclick={(e) => { e.preventDefault(); activeTab = 'deezer'; }}>Deezer</a>
</li>
<li role="tab" aria-selected={activeTab === 'advanced'}>
<a href="#advanced" onclick={(e) => { e.preventDefault(); activeTab = 'advanced'; }}>Advanced</a>
</li>
</menu>
<div class="settings-wrapper">
<h2 style="padding: 8px">Settings</h2>
<div class="window" role="tabpanel">
<div class="window-body">
<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.
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
The <menu> element IS interactive (contains clickable <button> elements) and the
role="tablist" properly describes the semantic purpose to assistive technology.
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
-->
<menu role="tablist">
<li role="tab" aria-selected={activeTab === 'library'}>
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
</li>
<li role="tab" aria-selected={activeTab === 'deezer'}>
<a href="#deezer" onclick={(e) => { e.preventDefault(); activeTab = 'deezer'; }}>Deezer</a>
</li>
<li role="tab" aria-selected={activeTab === 'advanced'}>
<a href="#advanced" onclick={(e) => { e.preventDefault(); activeTab = 'advanced'; }}>Advanced</a>
</li>
</menu>
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if activeTab === 'library'}
<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>
</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 {