mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(dz): add playlist download, existence check, and improved queue handling
Add ability to download entire playlists as M3U8 files, with UI integration and per-track download actions. Implement track existence checking to avoid duplicate downloads, respecting the overwrite setting. Improve queue manager to sync downloaded tracks to the library incrementally. Refactor playlist parsing and metadata reading to use the Rust backend for better performance and accuracy. Update UI to reflect track existence and download status in playlist views. BREAKING CHANGE: Deezer playlist and track download logic now relies on Rust backend for metadata and new existence checking; some APIs and internal behaviors have changed.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
|
|
||||||
mod tagger;
|
mod tagger;
|
||||||
|
mod metadata;
|
||||||
|
|
||||||
// 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]
|
||||||
@@ -19,6 +20,12 @@ fn tag_audio_file(
|
|||||||
tagger::tag_audio_file(&path, &metadata, cover_data.as_deref(), embed_lyrics)
|
tagger::tag_audio_file(&path, &metadata, cover_data.as_deref(), embed_lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read metadata from an audio file (MP3 or FLAC)
|
||||||
|
#[tauri::command]
|
||||||
|
fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String> {
|
||||||
|
metadata::read_audio_metadata(&path)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let library_migrations = vec![Migration {
|
let library_migrations = vec![Migration {
|
||||||
@@ -136,7 +143,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet, tag_audio_file])
|
.invoke_handler(tauri::generate_handler![greet, tag_audio_file, read_audio_metadata])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
87
src-tauri/src/metadata.rs
Normal file
87
src-tauri/src/metadata.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use metaflac::Tag as FlacTag;
|
||||||
|
use id3::{Tag as ID3Tag, TagLike};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Audio file metadata structure
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AudioMetadata {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub album_artist: Option<String>,
|
||||||
|
pub track_number: Option<u32>,
|
||||||
|
pub duration: Option<f64>, // in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read metadata from an audio file (MP3 or FLAC)
|
||||||
|
pub fn read_audio_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||||
|
let path_obj = Path::new(path);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if !path_obj.exists() {
|
||||||
|
return Err(format!("File not found: {}", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine file type by extension
|
||||||
|
let extension = path_obj
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.to_lowercase())
|
||||||
|
.ok_or_else(|| "File has no extension".to_string())?;
|
||||||
|
|
||||||
|
match extension.as_str() {
|
||||||
|
"mp3" => read_mp3_metadata(path),
|
||||||
|
"flac" => read_flac_metadata(path),
|
||||||
|
_ => Err(format!("Unsupported file format: {}", extension)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))?;
|
||||||
|
|
||||||
|
Ok(AudioMetadata {
|
||||||
|
title: tag.title().map(|s| s.to_string()),
|
||||||
|
artist: tag.artist().map(|s| s.to_string()),
|
||||||
|
album: tag.album().map(|s| s.to_string()),
|
||||||
|
album_artist: tag.album_artist().map(|s| s.to_string()),
|
||||||
|
track_number: tag.track(),
|
||||||
|
duration: tag.duration().map(|d| d as f64 / 1000.0), // Convert ms to seconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))?;
|
||||||
|
|
||||||
|
// Helper to get first value from vorbis comment
|
||||||
|
let get_first = |key: &str| -> Option<String> {
|
||||||
|
tag.vorbis_comments()
|
||||||
|
.and_then(|vorbis| vorbis.get(key))
|
||||||
|
.and_then(|values| values.first().map(|s| s.to_string()))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse track number
|
||||||
|
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| {
|
||||||
|
let samples = info.total_samples;
|
||||||
|
let sample_rate = info.sample_rate;
|
||||||
|
samples as f64 / sample_rate as f64
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(AudioMetadata {
|
||||||
|
title: get_first("TITLE"),
|
||||||
|
artist: get_first("ARTIST"),
|
||||||
|
album: get_first("ALBUM"),
|
||||||
|
album_artist: get_first("ALBUMARTIST"),
|
||||||
|
track_number,
|
||||||
|
duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -102,10 +102,10 @@
|
|||||||
<td class="track-number">
|
<td class="track-number">
|
||||||
{track.metadata.trackNumber ?? i + 1}
|
{track.metadata.trackNumber ?? i + 1}
|
||||||
</td>
|
</td>
|
||||||
<td>{track.metadata.title || track.filename}</td>
|
<td>{track.metadata.title ?? '—'}</td>
|
||||||
{#if showAlbumColumn}
|
{#if showAlbumColumn}
|
||||||
<td>{track.metadata.artist || '—'}</td>
|
<td>{track.metadata.artist ?? '—'}</td>
|
||||||
<td>{track.metadata.album || '—'}</td>
|
<td>{track.metadata.album ?? '—'}</td>
|
||||||
{/if}
|
{/if}
|
||||||
<td class="duration">
|
<td class="duration">
|
||||||
{#if track.metadata.duration}
|
{#if track.metadata.duration}
|
||||||
|
|||||||
325
src/lib/components/DeezerCollectionView.svelte
Normal file
325
src/lib/components/DeezerCollectionView.svelte
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Track } from '$lib/types/track';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
metadata?: string;
|
||||||
|
coverImageUrl?: string;
|
||||||
|
tracks: Track[];
|
||||||
|
trackExistsMap: Map<string, boolean>; // Map of track ID to existence
|
||||||
|
selectedTrackIndex?: number | null;
|
||||||
|
onTrackClick?: (index: number) => void;
|
||||||
|
onDownloadTrack?: (index: number) => void;
|
||||||
|
onDownloadPlaylist?: () => void;
|
||||||
|
downloadingTrackIds?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
metadata,
|
||||||
|
coverImageUrl,
|
||||||
|
tracks,
|
||||||
|
trackExistsMap,
|
||||||
|
selectedTrackIndex = null,
|
||||||
|
onTrackClick,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadPlaylist,
|
||||||
|
downloadingTrackIds = new Set()
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
type ViewMode = 'tracks' | 'info';
|
||||||
|
let viewMode = $state<ViewMode>('tracks');
|
||||||
|
|
||||||
|
function handleTrackClick(index: number) {
|
||||||
|
if (onTrackClick) {
|
||||||
|
onTrackClick(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadClick(index: number, event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (onDownloadTrack) {
|
||||||
|
onDownloadTrack(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get track ID for existence checking
|
||||||
|
function getTrackId(track: Track): string | undefined {
|
||||||
|
// Assuming track has metadata with a Deezer ID stored
|
||||||
|
return (track as any).deezerId?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackInLibrary(track: Track): boolean {
|
||||||
|
const trackId = getTrackId(track);
|
||||||
|
if (!trackId) return false;
|
||||||
|
return trackExistsMap.get(trackId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackDownloading(track: Track): boolean {
|
||||||
|
const trackId = getTrackId(track);
|
||||||
|
if (!trackId) return false;
|
||||||
|
return downloadingTrackIds.has(trackId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="collection-header">
|
||||||
|
{#if coverImageUrl}
|
||||||
|
<img
|
||||||
|
src={coverImageUrl}
|
||||||
|
alt="{title} cover"
|
||||||
|
class="collection-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="collection-cover-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="collection-info">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{#if subtitle}
|
||||||
|
<p class="collection-subtitle">{subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
{#if metadata}
|
||||||
|
<p class="collection-metadata">{metadata}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="collection-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
-->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={viewMode === 'tracks'}>
|
||||||
|
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'info'}>
|
||||||
|
<button onclick={() => viewMode = 'info'}>Info</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
{#if viewMode === 'tracks'}
|
||||||
|
<!-- Track Listing -->
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;">#</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th style="width: 80px;">In Library</th>
|
||||||
|
<th style="width: 100px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tracks as track, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedTrackIndex === i}
|
||||||
|
onclick={() => handleTrackClick(i)}
|
||||||
|
>
|
||||||
|
<td class="track-number">
|
||||||
|
{track.metadata.trackNumber ?? i + 1}
|
||||||
|
</td>
|
||||||
|
<td>{track.metadata.title ?? '—'}</td>
|
||||||
|
<td>{track.metadata.artist ?? '—'}</td>
|
||||||
|
<td>{track.metadata.album ?? '—'}</td>
|
||||||
|
<td class="duration">
|
||||||
|
{#if track.metadata.duration}
|
||||||
|
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="in-library">
|
||||||
|
{isTrackInLibrary(track) ? '✓' : '✗'}
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
onclick={(e) => handleDownloadClick(i, e)}
|
||||||
|
disabled={isTrackDownloading(track)}
|
||||||
|
class="download-btn"
|
||||||
|
>
|
||||||
|
{isTrackDownloading(track) ? 'Queued' : 'Download'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'info'}
|
||||||
|
<!-- Playlist Info -->
|
||||||
|
<div class="info-container">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Playlist Information</legend>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Title:</span>
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
{#if subtitle}
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Creator:</span>
|
||||||
|
<span>{subtitle}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Tracks:</span>
|
||||||
|
<span>{tracks.length}</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style="margin-top: 16px;">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
<button onclick={onDownloadPlaylist}>
|
||||||
|
Download Playlist
|
||||||
|
</button>
|
||||||
|
<p class="help-text">Download all tracks and save as m3u8 playlist</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.collection-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-cover {
|
||||||
|
width: 152px;
|
||||||
|
height: 152px;
|
||||||
|
object-fit: cover;
|
||||||
|
image-rendering: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-cover-placeholder {
|
||||||
|
width: 152px;
|
||||||
|
height: 152px;
|
||||||
|
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-subtitle {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-metadata {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.in-library {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
203
src/lib/library/incrementalSync.ts
Normal file
203
src/lib/library/incrementalSync.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Incremental library sync
|
||||||
|
* Syncs a single artist or album folder to the database without full rescan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readDir, exists } from '@tauri-apps/plugin-fs';
|
||||||
|
import { upsertArtist, upsertAlbum, getAlbumByPath } from './database';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a single album folder to the database
|
||||||
|
* @param albumPath - Absolute path to album folder
|
||||||
|
* @param artistName - Name of the artist
|
||||||
|
* @param artistId - ID of the artist in database
|
||||||
|
* @returns True if album was synced successfully
|
||||||
|
*/
|
||||||
|
export async function syncAlbumFolder(
|
||||||
|
albumPath: string,
|
||||||
|
artistName: string,
|
||||||
|
artistId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Check if album folder exists
|
||||||
|
if (!(await exists(albumPath))) {
|
||||||
|
console.warn(`[IncrementalSync] Album folder does not exist: ${albumPath}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get album name from path
|
||||||
|
const albumName = albumPath.split('/').pop() || '';
|
||||||
|
|
||||||
|
// Count tracks in album
|
||||||
|
const entries = await readDir(albumPath);
|
||||||
|
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3'];
|
||||||
|
let trackCount = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
const hasAudioExt = audioExtensions.some(ext => entry.name.endsWith(ext));
|
||||||
|
if (hasAudioExt) {
|
||||||
|
trackCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackCount === 0) {
|
||||||
|
console.warn(`[IncrementalSync] No tracks found in album: ${albumPath}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find cover art
|
||||||
|
let coverPath: string | undefined;
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG'];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
const hasImageExt = imageExtensions.some(ext => entry.name.endsWith(ext));
|
||||||
|
if (hasImageExt) {
|
||||||
|
coverPath = `${albumPath}/${entry.name}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert album
|
||||||
|
await upsertAlbum({
|
||||||
|
artist_id: artistId,
|
||||||
|
artist_name: artistName,
|
||||||
|
title: albumName,
|
||||||
|
path: albumPath,
|
||||||
|
cover_path: coverPath,
|
||||||
|
track_count: trackCount
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[IncrementalSync] Synced album: ${artistName} - ${albumName} (${trackCount} tracks)`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[IncrementalSync] Error syncing album ${albumPath}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a single artist folder to the database
|
||||||
|
* This will sync the artist and all their albums
|
||||||
|
* @param artistPath - Absolute path to artist folder
|
||||||
|
* @returns True if artist was synced successfully
|
||||||
|
*/
|
||||||
|
export async function syncArtistFolder(artistPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Check if artist folder exists
|
||||||
|
if (!(await exists(artistPath))) {
|
||||||
|
console.warn(`[IncrementalSync] Artist folder does not exist: ${artistPath}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get artist name from path
|
||||||
|
const artistName = artistPath.split('/').pop() || '';
|
||||||
|
|
||||||
|
// Get all album folders
|
||||||
|
const entries = await readDir(artistPath);
|
||||||
|
const albumFolders = entries.filter(e => e.isDirectory);
|
||||||
|
|
||||||
|
if (albumFolders.length === 0) {
|
||||||
|
console.warn(`[IncrementalSync] No albums found for artist: ${artistName}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync all albums and collect stats
|
||||||
|
let totalTracks = 0;
|
||||||
|
let albumCount = 0;
|
||||||
|
let primaryCover: string | undefined;
|
||||||
|
|
||||||
|
for (const albumEntry of albumFolders) {
|
||||||
|
const albumPath = `${artistPath}/${albumEntry.name}`;
|
||||||
|
|
||||||
|
// Count tracks
|
||||||
|
const albumEntries = await readDir(albumPath);
|
||||||
|
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3'];
|
||||||
|
let trackCount = 0;
|
||||||
|
|
||||||
|
for (const entry of albumEntries) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
const hasAudioExt = audioExtensions.some(ext => entry.name.endsWith(ext));
|
||||||
|
if (hasAudioExt) {
|
||||||
|
trackCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackCount > 0) {
|
||||||
|
albumCount++;
|
||||||
|
totalTracks += trackCount;
|
||||||
|
|
||||||
|
// Find cover for first album
|
||||||
|
if (!primaryCover) {
|
||||||
|
for (const entry of albumEntries) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG'];
|
||||||
|
const hasImageExt = imageExtensions.some(ext => entry.name.endsWith(ext));
|
||||||
|
if (hasImageExt) {
|
||||||
|
primaryCover = `${albumPath}/${entry.name}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert artist
|
||||||
|
const artistId = await upsertArtist({
|
||||||
|
name: artistName,
|
||||||
|
path: artistPath,
|
||||||
|
album_count: albumCount,
|
||||||
|
track_count: totalTracks,
|
||||||
|
primary_cover_path: primaryCover
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now sync all albums with the artist ID
|
||||||
|
for (const albumEntry of albumFolders) {
|
||||||
|
const albumPath = `${artistPath}/${albumEntry.name}`;
|
||||||
|
await syncAlbumFolder(albumPath, artistName, artistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[IncrementalSync] Synced artist: ${artistName} (${albumCount} albums, ${totalTracks} tracks)`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[IncrementalSync] Error syncing artist ${artistPath}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync multiple tracks by their parent album/artist folders
|
||||||
|
* Groups tracks by artist and syncs each artist once
|
||||||
|
* @param trackPaths - Array of absolute paths to track files
|
||||||
|
* @returns Number of artists synced
|
||||||
|
*/
|
||||||
|
export async function syncTrackPaths(trackPaths: string[]): Promise<number> {
|
||||||
|
// Group tracks by artist path
|
||||||
|
const artistPaths = new Set<string>();
|
||||||
|
|
||||||
|
for (const trackPath of trackPaths) {
|
||||||
|
// Extract artist path (two levels up from track)
|
||||||
|
// Format: /path/to/Music/Artist/Album/Track.flac
|
||||||
|
const parts = trackPath.split('/');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const artistPath = parts.slice(0, -2).join('/');
|
||||||
|
artistPaths.add(artistPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync each artist
|
||||||
|
let syncedCount = 0;
|
||||||
|
for (const artistPath of artistPaths) {
|
||||||
|
const success = await syncArtistFolder(artistPath);
|
||||||
|
if (success) {
|
||||||
|
syncedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[IncrementalSync] Synced ${syncedCount} artists from ${trackPaths.length} track paths`);
|
||||||
|
return syncedCount;
|
||||||
|
}
|
||||||
89
src/lib/library/m3u8.ts
Normal file
89
src/lib/library/m3u8.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
|
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||||
|
|
||||||
|
export interface M3U8Track {
|
||||||
|
duration: number; // in seconds
|
||||||
|
artist: string;
|
||||||
|
title: string;
|
||||||
|
path: string; // relative path from playlist file (e.g., ../Music/Artist/Album/01 - Track.flac)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an M3U8 playlist file
|
||||||
|
* Format: Extended M3U format with EXTINF metadata
|
||||||
|
*
|
||||||
|
* @param playlistName - Name of the playlist (will be sanitized)
|
||||||
|
* @param tracks - Array of tracks to include
|
||||||
|
* @param playlistsFolder - Absolute path to playlists folder
|
||||||
|
* @returns Absolute path to created m3u8 file
|
||||||
|
*/
|
||||||
|
export async function writeM3U8(
|
||||||
|
playlistName: string,
|
||||||
|
tracks: M3U8Track[],
|
||||||
|
playlistsFolder: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Sanitize playlist name for filename
|
||||||
|
const sanitizedName = sanitizeFilename(playlistName);
|
||||||
|
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
|
||||||
|
|
||||||
|
// Build m3u8 content
|
||||||
|
const lines: string[] = [
|
||||||
|
'#EXTM3U',
|
||||||
|
`#PLAYLIST:${playlistName}`,
|
||||||
|
'#EXTENC:UTF-8',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
// EXTINF format: #EXTINF:duration,artist - title
|
||||||
|
const durationSeconds = Math.round(track.duration);
|
||||||
|
const extinf = `#EXTINF:${durationSeconds},${track.artist} - ${track.title}`;
|
||||||
|
lines.push(extinf);
|
||||||
|
lines.push(track.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add trailing newline
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
const content = lines.join('\n');
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(content);
|
||||||
|
|
||||||
|
await writeFile(playlistPath, data);
|
||||||
|
|
||||||
|
return playlistPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert absolute music file path to relative path from playlists folder
|
||||||
|
* Assumes music folder and playlists folder are siblings:
|
||||||
|
* /path/to/Music/Artist/Album/Track.flac
|
||||||
|
* /path/to/Playlists/playlist.m3u8
|
||||||
|
* Becomes: ../Music/Artist/Album/Track.flac
|
||||||
|
*
|
||||||
|
* @param absoluteMusicPath - Absolute path to music file
|
||||||
|
* @param musicFolderName - Name of music folder (default: 'Music')
|
||||||
|
* @returns Relative path from playlists folder
|
||||||
|
*/
|
||||||
|
export function makeRelativePath(
|
||||||
|
absoluteMusicPath: string,
|
||||||
|
musicFolderName: string = 'Music'
|
||||||
|
): string {
|
||||||
|
// Split path into parts
|
||||||
|
const parts = absoluteMusicPath.split('/');
|
||||||
|
|
||||||
|
// Find the music folder index
|
||||||
|
const musicIndex = parts.findIndex(part => part === musicFolderName);
|
||||||
|
|
||||||
|
if (musicIndex === -1) {
|
||||||
|
// Fallback: if music folder not found, use the path as-is
|
||||||
|
console.warn(`[M3U8] Could not find "${musicFolderName}" in path: ${absoluteMusicPath}`);
|
||||||
|
return absoluteMusicPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take everything from music folder onwards
|
||||||
|
const relativeParts = parts.slice(musicIndex);
|
||||||
|
|
||||||
|
// Prepend ../ to go up from playlists folder
|
||||||
|
return `../${relativeParts.join('/')}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readTextFile, readFile, exists, readDir } from '@tauri-apps/plugin-fs';
|
import { readTextFile, exists, readDir } from '@tauri-apps/plugin-fs';
|
||||||
import { parseBuffer } from 'music-metadata';
|
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,34 +25,66 @@ function getAudioFormat(filename: string): AudioFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedPlaylistTrack {
|
||||||
|
path: string;
|
||||||
|
extinfData?: {
|
||||||
|
duration: number;
|
||||||
|
artist?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
* Returns tracks with optional EXTINF metadata
|
||||||
*/
|
*/
|
||||||
export async function parsePlaylist(playlistPath: string): Promise<string[]> {
|
export async function parsePlaylist(playlistPath: string): Promise<ParsedPlaylistTrack[]> {
|
||||||
try {
|
try {
|
||||||
const content = await readTextFile(playlistPath);
|
const content = await readTextFile(playlistPath);
|
||||||
const lines = content.split('\n').map(line => line.trim());
|
const lines = content.split('\n').map(line => line.trim());
|
||||||
|
|
||||||
const tracks: string[] = [];
|
const tracks: ParsedPlaylistTrack[] = [];
|
||||||
|
let currentExtinf: { duration: number; artist?: string; title?: string } | undefined;
|
||||||
|
|
||||||
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 comments (except #EXTINF which precedes track info)
|
// Skip empty lines and non-EXTINF comments
|
||||||
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
|
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's an EXTINF line, the next line should be the file path
|
// Parse EXTINF line: #EXTINF:duration,artist - title
|
||||||
if (line.startsWith('#EXTINF')) {
|
if (line.startsWith('#EXTINF')) {
|
||||||
i++; // Move to next line
|
const match = line.match(/^#EXTINF:(\d+),(.+)$/);
|
||||||
if (i < lines.length && lines[i] && !lines[i].startsWith('#')) {
|
if (match) {
|
||||||
tracks.push(lines[i]);
|
const duration = parseInt(match[1], 10);
|
||||||
|
const info = match[2];
|
||||||
|
|
||||||
|
// Try to split by " - " to get artist and title
|
||||||
|
const dashIndex = info.indexOf(' - ');
|
||||||
|
if (dashIndex !== -1) {
|
||||||
|
currentExtinf = {
|
||||||
|
duration,
|
||||||
|
artist: info.substring(0, dashIndex).trim(),
|
||||||
|
title: info.substring(dashIndex + 3).trim()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// No artist, just title
|
||||||
|
currentExtinf = {
|
||||||
|
duration,
|
||||||
|
title: info.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (!line.startsWith('#')) {
|
} else if (!line.startsWith('#')) {
|
||||||
// Regular M3U format - just file paths
|
// This is a file path
|
||||||
tracks.push(line);
|
tracks.push({
|
||||||
|
path: line,
|
||||||
|
extinfData: currentExtinf
|
||||||
|
});
|
||||||
|
currentExtinf = undefined; // Reset for next track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,41 +141,37 @@ async function findActualFilePath(basePath: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read metadata from audio file
|
* Read metadata from audio file using Rust backend
|
||||||
*/
|
*/
|
||||||
async function readAudioMetadata(filePath: string, format: AudioFormat): Promise<TrackMetadata> {
|
async function readAudioMetadata(filePath: string, format: AudioFormat): Promise<TrackMetadata> {
|
||||||
try {
|
try {
|
||||||
// Read file as binary
|
// Call Rust command to read metadata
|
||||||
const fileData = await readFile(filePath);
|
const metadata = await invoke<{
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
albumArtist?: string;
|
||||||
|
trackNumber?: number;
|
||||||
|
duration?: number;
|
||||||
|
}>('read_audio_metadata', { path: filePath });
|
||||||
|
|
||||||
// Get MIME type from format
|
const result: TrackMetadata = {
|
||||||
const mimeMap: Record<AudioFormat, string> = {
|
title: metadata.title,
|
||||||
'flac': 'audio/flac',
|
artist: metadata.artist,
|
||||||
'mp3': 'audio/mpeg',
|
album: metadata.album,
|
||||||
'opus': 'audio/opus',
|
albumArtist: metadata.albumArtist,
|
||||||
'ogg': 'audio/ogg',
|
trackNumber: metadata.trackNumber,
|
||||||
'm4a': 'audio/mp4',
|
duration: metadata.duration,
|
||||||
'wav': 'audio/wav',
|
|
||||||
'unknown': 'audio/mpeg'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse metadata from buffer
|
// Log what we got
|
||||||
const metadata = await parseBuffer(fileData, mimeMap[format], { duration: true });
|
if (!result.title && !result.artist && !result.album) {
|
||||||
|
console.warn(`[Playlist] No metadata found in file: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return result;
|
||||||
title: metadata.common.title,
|
|
||||||
artist: metadata.common.artist,
|
|
||||||
album: metadata.common.album,
|
|
||||||
albumArtist: metadata.common.albumartist,
|
|
||||||
year: metadata.common.year,
|
|
||||||
trackNumber: metadata.common.track?.no ?? undefined,
|
|
||||||
genre: metadata.common.genre?.[0],
|
|
||||||
duration: metadata.format.duration,
|
|
||||||
bitrate: metadata.format.bitrate ? Math.round(metadata.format.bitrate / 1000) : undefined,
|
|
||||||
sampleRate: metadata.format.sampleRate
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading audio metadata:', error);
|
console.error(`[Playlist] Error reading metadata from ${filePath}:`, error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,11 +218,13 @@ export async function loadPlaylistTracks(
|
|||||||
playlistName: string,
|
playlistName: string,
|
||||||
baseFolder: string
|
baseFolder: string
|
||||||
): Promise<PlaylistWithTracks> {
|
): Promise<PlaylistWithTracks> {
|
||||||
const trackPaths = await parsePlaylist(playlistPath);
|
const parsedTracks = await parsePlaylist(playlistPath);
|
||||||
|
|
||||||
// Load tracks with metadata in parallel
|
// Load tracks with metadata in parallel
|
||||||
const tracks: Track[] = await Promise.all(
|
const tracks: Track[] = await Promise.all(
|
||||||
trackPaths.map(async (trackPath) => {
|
parsedTracks.map(async (parsedTrack) => {
|
||||||
|
const trackPath = parsedTrack.path;
|
||||||
|
|
||||||
// Handle relative paths - resolve relative to playlist location or music folder
|
// Handle relative paths - resolve relative to playlist location or music folder
|
||||||
let fullPath = trackPath.startsWith('/') || trackPath.includes(':\\')
|
let fullPath = trackPath.startsWith('/') || trackPath.includes(':\\')
|
||||||
? trackPath // Absolute path
|
? trackPath // Absolute path
|
||||||
@@ -209,17 +239,29 @@ export async function loadPlaylistTracks(
|
|||||||
const filename = trackPath.split('/').pop() || trackPath.split('\\').pop() || trackPath;
|
const filename = trackPath.split('/').pop() || trackPath.split('\\').pop() || trackPath;
|
||||||
const format = getAudioFormat(filename);
|
const format = getAudioFormat(filename);
|
||||||
|
|
||||||
// Read metadata from actual audio file if found
|
// Start with EXTINF metadata if available
|
||||||
const metadata = actualPath
|
let metadata: TrackMetadata = {};
|
||||||
? await readAudioMetadata(actualPath, format)
|
if (parsedTrack.extinfData) {
|
||||||
: {};
|
metadata.title = parsedTrack.extinfData.title;
|
||||||
|
metadata.artist = parsedTrack.extinfData.artist;
|
||||||
|
metadata.duration = parsedTrack.extinfData.duration;
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback to filename parsing if no metadata
|
// Try to read metadata from actual audio file if found
|
||||||
if (!metadata.title) {
|
// Only override EXTINF data if file has actual metadata values
|
||||||
const nameWithoutExt = filename.replace(/\.(flac|mp3|opus|ogg|m4a|wav)$/i, '');
|
if (actualPath) {
|
||||||
const parts = nameWithoutExt.split(' - ');
|
const fileMetadata = await readAudioMetadata(actualPath, format);
|
||||||
metadata.title = parts.length > 1 ? parts[1] : nameWithoutExt;
|
// Merge, but only override if file metadata has values
|
||||||
metadata.artist = parts.length > 1 ? parts[0] : undefined;
|
if (fileMetadata.title) metadata.title = fileMetadata.title;
|
||||||
|
if (fileMetadata.artist) metadata.artist = fileMetadata.artist;
|
||||||
|
if (fileMetadata.album) metadata.album = fileMetadata.album;
|
||||||
|
if (fileMetadata.albumArtist) metadata.albumArtist = fileMetadata.albumArtist;
|
||||||
|
if (fileMetadata.duration !== undefined) metadata.duration = fileMetadata.duration;
|
||||||
|
if (fileMetadata.trackNumber) metadata.trackNumber = fileMetadata.trackNumber;
|
||||||
|
if (fileMetadata.year) metadata.year = fileMetadata.year;
|
||||||
|
if (fileMetadata.genre) metadata.genre = fileMetadata.genre;
|
||||||
|
if (fileMetadata.bitrate) metadata.bitrate = fileMetadata.bitrate;
|
||||||
|
if (fileMetadata.sampleRate) metadata.sampleRate = fileMetadata.sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
143
src/lib/library/trackMatcher.ts
Normal file
143
src/lib/library/trackMatcher.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { exists } from '@tauri-apps/plugin-fs';
|
||||||
|
import { generateTrackPath } from '$lib/services/deezer/paths';
|
||||||
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a Deezer track exists in the local music library
|
||||||
|
* Uses the same path generation logic as the downloader
|
||||||
|
*
|
||||||
|
* @param track - Deezer track to check
|
||||||
|
* @param musicFolder - Path to music folder
|
||||||
|
* @param format - Download format ('FLAC', 'MP3_320', 'MP3_128')
|
||||||
|
* @returns True if track file exists
|
||||||
|
*/
|
||||||
|
export async function deezerTrackExists(
|
||||||
|
track: DeezerTrack,
|
||||||
|
musicFolder: string,
|
||||||
|
format: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Generate the expected path using downloader logic
|
||||||
|
const paths = generateTrackPath(track, musicFolder, format, false);
|
||||||
|
const fullPath = `${paths.filepath}/${paths.filename}`;
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
return await exists(fullPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TrackMatcher] Error checking track existence:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check existence for multiple tracks in batch
|
||||||
|
* Uses caching to avoid repeated file system checks
|
||||||
|
*
|
||||||
|
* @param tracks - Array of Deezer tracks
|
||||||
|
* @param musicFolder - Path to music folder
|
||||||
|
* @param format - Download format
|
||||||
|
* @returns Map of track ID to existence status
|
||||||
|
*/
|
||||||
|
export async function batchCheckTracksExist(
|
||||||
|
tracks: DeezerTrack[],
|
||||||
|
musicFolder: string,
|
||||||
|
format: string
|
||||||
|
): Promise<Map<string, boolean>> {
|
||||||
|
const results = new Map<string, boolean>();
|
||||||
|
|
||||||
|
// Check all tracks in parallel
|
||||||
|
await Promise.all(
|
||||||
|
tracks.map(async (track) => {
|
||||||
|
const trackId = track.id.toString();
|
||||||
|
const exists = await deezerTrackExists(track, musicFolder, format);
|
||||||
|
results.set(trackId, exists);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-based cache for track existence checks
|
||||||
|
* Useful for avoiding repeated checks within a single view
|
||||||
|
*/
|
||||||
|
export class TrackExistenceCache {
|
||||||
|
private cache: Map<string, boolean> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if track exists, using cache if available
|
||||||
|
*/
|
||||||
|
async checkTrack(
|
||||||
|
track: DeezerTrack,
|
||||||
|
musicFolder: string,
|
||||||
|
format: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const cacheKey = this.getCacheKey(track, musicFolder, format);
|
||||||
|
|
||||||
|
if (this.cache.has(cacheKey)) {
|
||||||
|
return this.cache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await deezerTrackExists(track, musicFolder, format);
|
||||||
|
this.cache.set(cacheKey, exists);
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check multiple tracks, using cache when available
|
||||||
|
*/
|
||||||
|
async checkTracks(
|
||||||
|
tracks: DeezerTrack[],
|
||||||
|
musicFolder: string,
|
||||||
|
format: string
|
||||||
|
): Promise<Map<string, boolean>> {
|
||||||
|
const results = new Map<string, boolean>();
|
||||||
|
|
||||||
|
// Separate cached and uncached tracks
|
||||||
|
const uncachedTracks: DeezerTrack[] = [];
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
const cacheKey = this.getCacheKey(track, musicFolder, format);
|
||||||
|
if (this.cache.has(cacheKey)) {
|
||||||
|
results.set(track.id.toString(), this.cache.get(cacheKey)!);
|
||||||
|
} else {
|
||||||
|
uncachedTracks.push(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check uncached tracks in batch
|
||||||
|
if (uncachedTracks.length > 0) {
|
||||||
|
const uncachedResults = await batchCheckTracksExist(
|
||||||
|
uncachedTracks,
|
||||||
|
musicFolder,
|
||||||
|
format
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to cache and results
|
||||||
|
for (const [trackId, exists] of uncachedResults.entries()) {
|
||||||
|
const track = uncachedTracks.find(t => t.id.toString() === trackId);
|
||||||
|
if (track) {
|
||||||
|
const cacheKey = this.getCacheKey(track, musicFolder, format);
|
||||||
|
this.cache.set(cacheKey, exists);
|
||||||
|
}
|
||||||
|
results.set(trackId, exists);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache (useful when filesystem changes)
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for a track
|
||||||
|
*/
|
||||||
|
private getCacheKey(track: DeezerTrack, musicFolder: string, format: string): string {
|
||||||
|
return `${musicFolder}:${format}:${track.id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -412,7 +412,8 @@ export class DeezerAPI {
|
|||||||
console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken });
|
console.log('[DEBUG] Getting track download URL...', { trackToken, format, licenseToken });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cookieHeader = this.getCookieHeader();
|
// media.deezer.com ONLY needs arl cookie, not sid or other cookies
|
||||||
|
const cookieHeader = this.arl ? `arl=${this.arl}` : '';
|
||||||
console.log('[DEBUG] Sending request to media.deezer.com with cookies:', cookieHeader);
|
console.log('[DEBUG] Sending request to media.deezer.com with cookies:', cookieHeader);
|
||||||
|
|
||||||
const response = await fetch('https://media.deezer.com/v1/get_url', {
|
const response = await fetch('https://media.deezer.com/v1/get_url', {
|
||||||
|
|||||||
@@ -5,13 +5,24 @@
|
|||||||
|
|
||||||
import { deezerAPI } from '$lib/services/deezer';
|
import { deezerAPI } from '$lib/services/deezer';
|
||||||
import { addToQueue } from '$lib/stores/downloadQueue';
|
import { addToQueue } from '$lib/stores/downloadQueue';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { trackExists } from './downloader';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch track metadata and add to download queue
|
* Fetch track metadata and add to download queue
|
||||||
|
* Respects the overwrite setting - skips tracks that already exist if overwrite is false
|
||||||
* @param trackId - Deezer track ID
|
* @param trackId - Deezer track ID
|
||||||
* @returns Promise that resolves when track is added to queue
|
* @returns Promise that resolves when track is added to queue (or skipped message)
|
||||||
*/
|
*/
|
||||||
export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
|
export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: boolean; reason?: string }> {
|
||||||
|
// Ensure ARL is set for authentication
|
||||||
|
const authState = get(deezerAuth);
|
||||||
|
if (authState.arl) {
|
||||||
|
deezerAPI.setArl(authState.arl);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch full track data from GW API
|
// Fetch full track data from GW API
|
||||||
const trackInfo = await deezerAPI.getTrack(trackId);
|
const trackInfo = await deezerAPI.getTrack(trackId);
|
||||||
|
|
||||||
@@ -66,9 +77,9 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
|
|||||||
albumId: trackInfo.ALB_ID,
|
albumId: trackInfo.ALB_ID,
|
||||||
albumArtist: trackInfo.ART_NAME,
|
albumArtist: trackInfo.ART_NAME,
|
||||||
albumArtistId: trackInfo.ART_ID,
|
albumArtistId: trackInfo.ART_ID,
|
||||||
trackNumber: trackInfo.TRACK_NUMBER || 1,
|
trackNumber: typeof trackInfo.TRACK_NUMBER === 'number' ? trackInfo.TRACK_NUMBER : parseInt(trackInfo.TRACK_NUMBER, 10),
|
||||||
discNumber: trackInfo.DISK_NUMBER || 1,
|
discNumber: typeof trackInfo.DISK_NUMBER === 'number' ? trackInfo.DISK_NUMBER : parseInt(trackInfo.DISK_NUMBER, 10),
|
||||||
duration: trackInfo.DURATION,
|
duration: typeof trackInfo.DURATION === 'number' ? trackInfo.DURATION : parseInt(trackInfo.DURATION, 10),
|
||||||
explicit: trackInfo.EXPLICIT_LYRICS === 1,
|
explicit: trackInfo.EXPLICIT_LYRICS === 1,
|
||||||
md5Origin: trackInfo.MD5_ORIGIN,
|
md5Origin: trackInfo.MD5_ORIGIN,
|
||||||
mediaVersion: trackInfo.MEDIA_VERSION,
|
mediaVersion: trackInfo.MEDIA_VERSION,
|
||||||
@@ -84,6 +95,18 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
|
|||||||
copyright: trackInfo.COPYRIGHT
|
copyright: trackInfo.COPYRIGHT
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if we should skip this track (if it exists and overwrite is false)
|
||||||
|
const appSettings = get(settings);
|
||||||
|
|
||||||
|
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
|
||||||
|
const exists = await trackExists(track, appSettings.musicFolder, appSettings.deezerFormat);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
|
||||||
|
return { added: false, reason: 'already_exists' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add to queue (queue manager runs continuously in background)
|
// Add to queue (queue manager runs continuously in background)
|
||||||
await addToQueue({
|
await addToQueue({
|
||||||
source: 'deezer',
|
source: 'deezer',
|
||||||
@@ -93,4 +116,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<void> {
|
|||||||
totalTracks: 1,
|
totalTracks: 1,
|
||||||
downloadObject: track
|
downloadObject: track
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { added: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,18 +124,13 @@ export async function downloadTrack(
|
|||||||
|
|
||||||
// Apply tags (works for both MP3 and FLAC)
|
// Apply tags (works for both MP3 and FLAC)
|
||||||
console.log('Tagging audio file...');
|
console.log('Tagging audio file...');
|
||||||
try {
|
await tagAudioFile(
|
||||||
await tagAudioFile(
|
finalPath,
|
||||||
finalPath,
|
track,
|
||||||
track,
|
appSettings.embedCoverArt ? coverData : undefined,
|
||||||
appSettings.embedCoverArt ? coverData : undefined,
|
appSettings.embedLyrics
|
||||||
appSettings.embedLyrics
|
);
|
||||||
);
|
console.log('Tagging complete!');
|
||||||
console.log('Tagging complete!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to tag audio file:', error);
|
|
||||||
// Non-fatal error - file is still downloaded, just not tagged
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save LRC sidecar file if enabled
|
// Save LRC sidecar file if enabled
|
||||||
if (appSettings.saveLrcFile && track.lyrics?.sync) {
|
if (appSettings.saveLrcFile && track.lyrics?.sync) {
|
||||||
|
|||||||
93
src/lib/services/deezer/playlistDownloader.ts
Normal file
93
src/lib/services/deezer/playlistDownloader.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Download Deezer playlist - adds tracks to queue and creates m3u8 file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addDeezerTrackToQueue } from './addToQueue';
|
||||||
|
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
||||||
|
import { generateTrackPath } from './paths';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
import { mkdir } from '@tauri-apps/plugin-fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a Deezer playlist
|
||||||
|
* - Adds all tracks to the download queue (respects overwrite setting)
|
||||||
|
* - Creates an m3u8 playlist file with relative paths
|
||||||
|
*
|
||||||
|
* @param playlistName - Name of the playlist
|
||||||
|
* @param tracks - Array of DeezerTrack objects
|
||||||
|
* @param playlistsFolder - Path to playlists folder
|
||||||
|
* @param musicFolder - Path to music folder
|
||||||
|
* @returns Path to created m3u8 file
|
||||||
|
*/
|
||||||
|
export async function downloadDeezerPlaylist(
|
||||||
|
playlistName: string,
|
||||||
|
tracks: DeezerTrack[],
|
||||||
|
playlistsFolder: string,
|
||||||
|
musicFolder: string
|
||||||
|
): Promise<string> {
|
||||||
|
const appSettings = get(settings);
|
||||||
|
|
||||||
|
console.log(`[PlaylistDownloader] Starting download for playlist: ${playlistName}`);
|
||||||
|
console.log(`[PlaylistDownloader] Tracks: ${tracks.length}`);
|
||||||
|
|
||||||
|
// Ensure playlists folder exists
|
||||||
|
try {
|
||||||
|
await mkdir(playlistsFolder, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Folder might already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all tracks to download queue
|
||||||
|
// Note: Tracks from cache don't have md5Origin/mediaVersion/trackToken needed for download
|
||||||
|
// So we need to call addDeezerTrackToQueue which fetches full data from API
|
||||||
|
// We add a small delay between requests to avoid rate limiting
|
||||||
|
let addedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
const track = tracks[i];
|
||||||
|
try {
|
||||||
|
const result = await addDeezerTrackToQueue(track.id.toString());
|
||||||
|
if (result.added) {
|
||||||
|
addedCount++;
|
||||||
|
} else {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay between requests to avoid rate limiting (except after last track)
|
||||||
|
if (i < tracks.length - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PlaylistDownloader] Error adding track ${track.title}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[PlaylistDownloader] Added ${addedCount} tracks to queue, skipped ${skippedCount}`);
|
||||||
|
|
||||||
|
// Generate m3u8 file
|
||||||
|
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
|
||||||
|
// Generate expected path for this track
|
||||||
|
const paths = generateTrackPath(track, musicFolder, appSettings.deezerFormat, false);
|
||||||
|
const absolutePath = `${paths.filepath}/${paths.filename}`;
|
||||||
|
|
||||||
|
// Convert to relative path from playlists folder
|
||||||
|
const relativePath = makeRelativePath(absolutePath, 'Music');
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: track.duration,
|
||||||
|
artist: track.artist,
|
||||||
|
title: track.title,
|
||||||
|
path: relativePath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write m3u8 file
|
||||||
|
const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder);
|
||||||
|
|
||||||
|
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
||||||
|
|
||||||
|
return m3u8Path;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
type QueueItem
|
type QueueItem
|
||||||
} from '$lib/stores/downloadQueue';
|
} from '$lib/stores/downloadQueue';
|
||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { syncTrackPaths } from '$lib/library/incrementalSync';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { DeezerTrack } from '$lib/types/deezer';
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
@@ -121,6 +123,13 @@ export class DeezerQueueManager {
|
|||||||
throw new Error('Music folder not configured');
|
throw new Error('Music folder not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set ARL for authentication
|
||||||
|
const authState = get(deezerAuth);
|
||||||
|
if (!authState.arl) {
|
||||||
|
throw new Error('Deezer ARL not found - please log in');
|
||||||
|
}
|
||||||
|
deezerAPI.setArl(authState.arl);
|
||||||
|
|
||||||
// Get user data for license token
|
// Get user data for license token
|
||||||
const userData = await deezerAPI.getUserData();
|
const userData = await deezerAPI.getUserData();
|
||||||
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
||||||
@@ -169,6 +178,14 @@ export class DeezerQueueManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[DeezerQueueManager] Downloaded: ${filePath}`);
|
console.log(`[DeezerQueueManager] Downloaded: ${filePath}`);
|
||||||
|
|
||||||
|
// Trigger incremental library sync for this track
|
||||||
|
try {
|
||||||
|
await syncTrackPaths([filePath]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DeezerQueueManager] Error syncing track to library:', error);
|
||||||
|
// Non-fatal - track is downloaded, just not in database yet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,6 +276,18 @@ export class DeezerQueueManager {
|
|||||||
await Promise.all(running);
|
await Promise.all(running);
|
||||||
|
|
||||||
console.log(`[DeezerQueueManager] Collection complete: ${completedCount} succeeded, ${failedCount} failed`);
|
console.log(`[DeezerQueueManager] Collection complete: ${completedCount} succeeded, ${failedCount} failed`);
|
||||||
|
|
||||||
|
// Trigger incremental library sync for all successfully downloaded tracks
|
||||||
|
if (completedCount > 0) {
|
||||||
|
try {
|
||||||
|
const successfulPaths = results.filter(r => typeof r === 'string') as string[];
|
||||||
|
await syncTrackPaths(successfulPaths);
|
||||||
|
console.log(`[DeezerQueueManager] Synced ${successfulPaths.length} tracks to library`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DeezerQueueManager] Error syncing collection to library:', error);
|
||||||
|
// Non-fatal - tracks are downloaded, just not in database yet
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let playlists = $state<Playlist[]>([]);
|
let playlists = $state<Playlist[]>([]);
|
||||||
|
let playlistsLoadTimestamp = $state<number>(0);
|
||||||
|
|
||||||
// Count active downloads (queued or downloading)
|
// Count active downloads (queued or downloading)
|
||||||
let activeDownloads = $derived(
|
let activeDownloads = $derived(
|
||||||
@@ -20,12 +21,25 @@
|
|||||||
}).length
|
}).length
|
||||||
);
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
await loadSettings();
|
// Run async initialization
|
||||||
await loadPlaylists();
|
(async () => {
|
||||||
|
await loadSettings();
|
||||||
|
await loadPlaylists();
|
||||||
|
})();
|
||||||
|
|
||||||
// Start background queue processor
|
// Start background queue processor
|
||||||
deezerQueueManager.start();
|
deezerQueueManager.start();
|
||||||
|
|
||||||
|
// Start playlist folder watcher (poll every 5 seconds)
|
||||||
|
const playlistWatchInterval = setInterval(async () => {
|
||||||
|
await checkPlaylistsUpdate();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
clearInterval(playlistWatchInterval);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadPlaylists() {
|
async function loadPlaylists() {
|
||||||
@@ -35,10 +49,37 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
playlists = await scanPlaylists($settings.playlistsFolder);
|
playlists = await scanPlaylists($settings.playlistsFolder);
|
||||||
|
playlistsLoadTimestamp = Date.now();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading playlists:', e);
|
console.error('Error loading playlists:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if playlists folder has been modified since last load
|
||||||
|
* If so, reload the playlists
|
||||||
|
*/
|
||||||
|
async function checkPlaylistsUpdate() {
|
||||||
|
if (!$settings.playlistsFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simple approach: just rescan periodically
|
||||||
|
// A more sophisticated approach would use fs watch APIs
|
||||||
|
const newPlaylists = await scanPlaylists($settings.playlistsFolder);
|
||||||
|
|
||||||
|
// Check if playlist count or names changed
|
||||||
|
if (newPlaylists.length !== playlists.length ||
|
||||||
|
newPlaylists.some((p, i) => p.name !== playlists[i]?.name)) {
|
||||||
|
console.log('[Sidebar] Playlists updated, refreshing...');
|
||||||
|
playlists = newPlaylists;
|
||||||
|
playlistsLoadTimestamp = Date.now();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail - folder might not exist yet
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
@@ -67,22 +108,22 @@
|
|||||||
Services
|
Services
|
||||||
</summary>
|
</summary>
|
||||||
<div class="nav-submenu">
|
<div class="nav-submenu">
|
||||||
<a href="/services/spotify" class="nav-item nav-subitem">
|
<!-- <a href="/services/spotify" class="nav-item nav-subitem">
|
||||||
<img src="/icons/spotify.png" alt="" class="nav-icon" />
|
<img src="/icons/spotify.png" alt="" class="nav-icon" />
|
||||||
Spotify
|
Spotify
|
||||||
</a>
|
</a> -->
|
||||||
<a href="/services/deezer" class="nav-item nav-subitem">
|
<a href="/services/deezer" class="nav-item nav-subitem">
|
||||||
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
||||||
Deezer
|
Deezer
|
||||||
</a>
|
</a>
|
||||||
<a href="/services/soulseek" class="nav-item nav-subitem">
|
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
|
||||||
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
||||||
Soulseek
|
Soulseek
|
||||||
</a>
|
</a> -->
|
||||||
<a href="/services/musicbrainz" class="nav-item nav-subitem">
|
<!-- <a href="/services/musicbrainz" class="nav-item nav-subitem">
|
||||||
<img src="/icons/musicbrainz.svg" alt="" class="nav-icon" />
|
<img src="/icons/musicbrainz.svg" alt="" class="nav-icon" />
|
||||||
MusicBrainz
|
MusicBrainz
|
||||||
</a>
|
</a> -->
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="nav-collapsible" open>
|
<details class="nav-collapsible" open>
|
||||||
|
|||||||
@@ -17,7 +17,15 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
await loadPlaylist();
|
});
|
||||||
|
|
||||||
|
// Reactive effect: reload playlist when name changes
|
||||||
|
$effect(() => {
|
||||||
|
// Track the dependency
|
||||||
|
playlistName;
|
||||||
|
|
||||||
|
// Load the playlist
|
||||||
|
loadPlaylist();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadPlaylist() {
|
async function loadPlaylist() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { deezerAuth } from '$lib/stores/deezer';
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
import { deezerAPI } from '$lib/services/deezer';
|
import { deezerAPI } from '$lib/services/deezer';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
import {
|
import {
|
||||||
getCachedPlaylist,
|
getCachedPlaylist,
|
||||||
getCachedPlaylistTracks,
|
getCachedPlaylistTracks,
|
||||||
@@ -10,24 +11,30 @@
|
|||||||
upsertPlaylistTracks,
|
upsertPlaylistTracks,
|
||||||
type DeezerPlaylistTrack
|
type DeezerPlaylistTrack
|
||||||
} from '$lib/library/deezer-database';
|
} from '$lib/library/deezer-database';
|
||||||
|
import { TrackExistenceCache } from '$lib/library/trackMatcher';
|
||||||
|
import { addDeezerTrackToQueue } from '$lib/services/deezer/addToQueue';
|
||||||
|
import { downloadDeezerPlaylist } from '$lib/services/deezer/playlistDownloader';
|
||||||
|
import DeezerCollectionView from '$lib/components/DeezerCollectionView.svelte';
|
||||||
import type { Track } from '$lib/types/track';
|
import type { Track } from '$lib/types/track';
|
||||||
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
type ViewMode = 'tracks' | 'info';
|
|
||||||
|
|
||||||
let viewMode = $state<ViewMode>('tracks');
|
|
||||||
let playlistId = $derived($page.params.id ?? '');
|
let playlistId = $derived($page.params.id ?? '');
|
||||||
let playlistTitle = $state('');
|
let playlistTitle = $state('');
|
||||||
let playlistCreator = $state('');
|
let playlistCreator = $state('');
|
||||||
let playlistTrackCount = $state(0);
|
let playlistTrackCount = $state(0);
|
||||||
let playlistPicture = $state<string | undefined>(undefined);
|
let playlistPicture = $state<string | undefined>(undefined);
|
||||||
let tracks = $state<Track[]>([]);
|
let tracks = $state<Track[]>([]);
|
||||||
|
let deezerTracks = $state<DeezerTrack[]>([]); // Store original Deezer tracks for downloading
|
||||||
|
let trackExistsMap = $state<Map<string, boolean>>(new Map());
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let selectedTrackIndex = $state<number | null>(null);
|
let selectedTrackIndex = $state<number | null>(null);
|
||||||
let lastCached = $state<number | null>(null);
|
let lastCached = $state<number | null>(null);
|
||||||
|
let downloadingTrackIds = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
const isFavoriteTracks = $derived(playlistId === 'favorite-tracks');
|
const isFavoriteTracks = $derived(playlistId === 'favorite-tracks');
|
||||||
|
const existenceCache = new TrackExistenceCache();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadPlaylist();
|
await loadPlaylist();
|
||||||
@@ -45,7 +52,9 @@
|
|||||||
|
|
||||||
const favTracks = await getCachedTracks();
|
const favTracks = await getCachedTracks();
|
||||||
playlistTrackCount = favTracks.length;
|
playlistTrackCount = favTracks.length;
|
||||||
tracks = favTracks.map(convertDeezerTrackToTrack);
|
|
||||||
|
// Convert database tracks - we'll need to fetch full data for downloads
|
||||||
|
tracks = favTracks.map(convertFavTrackToTrack);
|
||||||
lastCached = favTracks[0]?.cached_at || null;
|
lastCached = favTracks[0]?.cached_at || null;
|
||||||
} else {
|
} else {
|
||||||
// Load regular playlist
|
// Load regular playlist
|
||||||
@@ -70,9 +79,12 @@
|
|||||||
if (cachedTracks.length === 0) {
|
if (cachedTracks.length === 0) {
|
||||||
await refreshPlaylistTracks();
|
await refreshPlaylistTracks();
|
||||||
} else {
|
} else {
|
||||||
tracks = cachedTracks.map(convertPlaylistTrackToTrack);
|
tracks = cachedTracks.map(convertDbTrackToTrack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check track existence after loading tracks
|
||||||
|
await checkTrackExistence();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e));
|
error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -80,6 +92,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which tracks exist in the local library
|
||||||
|
*/
|
||||||
|
async function checkTrackExistence() {
|
||||||
|
if (!$settings.musicFolder || tracks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build minimal DeezerTrack objects from cached database data
|
||||||
|
// This is much faster than fetching from API
|
||||||
|
const deezerTracksData: DeezerTrack[] = tracks
|
||||||
|
.filter(track => (track as any).deezerId)
|
||||||
|
.map(track => {
|
||||||
|
const trackId = (track as any).deezerId;
|
||||||
|
return {
|
||||||
|
id: parseInt(trackId, 10),
|
||||||
|
title: track.metadata.title || 'Unknown',
|
||||||
|
artist: track.metadata.artist || 'Unknown',
|
||||||
|
artistId: 0, // Not needed for path generation
|
||||||
|
artists: [track.metadata.artist || 'Unknown'],
|
||||||
|
album: track.metadata.album || 'Unknown',
|
||||||
|
albumId: 0, // Not needed for path generation
|
||||||
|
albumArtist: track.metadata.artist || 'Unknown', // Use artist as album artist
|
||||||
|
albumArtistId: 0, // Not needed for path generation
|
||||||
|
trackNumber: track.metadata.trackNumber || 1,
|
||||||
|
discNumber: 1, // Assume single disc
|
||||||
|
duration: track.metadata.duration || 0,
|
||||||
|
explicit: false,
|
||||||
|
// These fields are only needed for downloading, not for existence checking
|
||||||
|
md5Origin: undefined,
|
||||||
|
mediaVersion: undefined,
|
||||||
|
trackToken: undefined
|
||||||
|
} as DeezerTrack;
|
||||||
|
});
|
||||||
|
|
||||||
|
deezerTracks = deezerTracksData;
|
||||||
|
|
||||||
|
// Check existence using cache
|
||||||
|
const existenceResults = await existenceCache.checkTracks(
|
||||||
|
deezerTracksData,
|
||||||
|
$settings.musicFolder,
|
||||||
|
$settings.deezerFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
trackExistsMap = existenceResults;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking track existence:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshPlaylistTracks() {
|
async function refreshPlaylistTracks() {
|
||||||
if (!$deezerAuth.arl || refreshing) {
|
if (!$deezerAuth.arl || refreshing) {
|
||||||
return;
|
return;
|
||||||
@@ -101,9 +164,12 @@
|
|||||||
|
|
||||||
// Reload from cache
|
// Reload from cache
|
||||||
const cachedTracks = await getCachedPlaylistTracks(playlistId);
|
const cachedTracks = await getCachedPlaylistTracks(playlistId);
|
||||||
tracks = cachedTracks.map(convertPlaylistTrackToTrack);
|
tracks = cachedTracks.map(convertDbTrackToTrack);
|
||||||
lastCached = Math.floor(Date.now() / 1000);
|
lastCached = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Re-check track existence
|
||||||
|
await checkTrackExistence();
|
||||||
|
|
||||||
console.log('[Deezer Playlist] Refresh complete!');
|
console.log('[Deezer Playlist] Refresh complete!');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error refreshing playlist tracks:', e);
|
console.error('Error refreshing playlist tracks:', e);
|
||||||
@@ -113,39 +179,108 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertPlaylistTrackToTrack(deezerTrack: DeezerPlaylistTrack): Track {
|
/**
|
||||||
return {
|
* Convert database playlist track to Track format with Deezer ID attached
|
||||||
path: '', // Deezer tracks don't have a local path
|
*/
|
||||||
filename: deezerTrack.title,
|
function convertDbTrackToTrack(dbTrack: DeezerPlaylistTrack): Track {
|
||||||
|
const track: Track = {
|
||||||
|
path: '',
|
||||||
|
filename: dbTrack.title,
|
||||||
format: 'unknown',
|
format: 'unknown',
|
||||||
metadata: {
|
metadata: {
|
||||||
title: deezerTrack.title,
|
title: dbTrack.title,
|
||||||
artist: deezerTrack.artist_name,
|
artist: dbTrack.artist_name,
|
||||||
album: deezerTrack.album_title || undefined,
|
album: dbTrack.album_title || undefined,
|
||||||
trackNumber: deezerTrack.track_number || undefined,
|
trackNumber: dbTrack.track_number || undefined,
|
||||||
duration: deezerTrack.duration
|
duration: dbTrack.duration
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attach Deezer ID for existence checking and downloading
|
||||||
|
(track as any).deezerId = dbTrack.track_id;
|
||||||
|
|
||||||
|
return track;
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertDeezerTrackToTrack(deezerTrack: any): Track {
|
/**
|
||||||
return {
|
* Convert database favorite track to Track format with Deezer ID attached
|
||||||
|
*/
|
||||||
|
function convertFavTrackToTrack(dbTrack: import('$lib/library/deezer-database').DeezerTrack): Track {
|
||||||
|
const track: Track = {
|
||||||
path: '',
|
path: '',
|
||||||
filename: deezerTrack.title,
|
filename: dbTrack.title,
|
||||||
format: 'unknown',
|
format: 'unknown',
|
||||||
metadata: {
|
metadata: {
|
||||||
title: deezerTrack.title,
|
title: dbTrack.title,
|
||||||
artist: deezerTrack.artist_name,
|
artist: dbTrack.artist_name,
|
||||||
album: deezerTrack.album_title || undefined,
|
album: dbTrack.album_title || undefined,
|
||||||
duration: deezerTrack.duration
|
duration: dbTrack.duration
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attach Deezer ID for existence checking and downloading
|
||||||
|
(track as any).deezerId = dbTrack.id;
|
||||||
|
|
||||||
|
return track;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrackClick(index: number) {
|
function handleTrackClick(index: number) {
|
||||||
selectedTrackIndex = index;
|
selectedTrackIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDownloadTrack(index: number) {
|
||||||
|
const track = tracks[index];
|
||||||
|
const trackId = (track as any).deezerId;
|
||||||
|
|
||||||
|
if (!trackId) {
|
||||||
|
console.error('Track has no Deezer ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
downloadingTrackIds.add(trackId);
|
||||||
|
downloadingTrackIds = downloadingTrackIds; // Trigger reactivity
|
||||||
|
|
||||||
|
await addDeezerTrackToQueue(trackId);
|
||||||
|
|
||||||
|
console.log(`Track "${track.metadata.title}" added to download queue`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding track to queue:', err);
|
||||||
|
} finally {
|
||||||
|
downloadingTrackIds.delete(trackId);
|
||||||
|
downloadingTrackIds = downloadingTrackIds; // Trigger reactivity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadPlaylist() {
|
||||||
|
if (deezerTracks.length === 0) {
|
||||||
|
console.error('No tracks to download');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$settings.playlistsFolder) {
|
||||||
|
console.error('Playlists folder not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Downloading playlist "${playlistTitle}"...`);
|
||||||
|
|
||||||
|
const m3u8Path = await downloadDeezerPlaylist(
|
||||||
|
playlistTitle,
|
||||||
|
deezerTracks,
|
||||||
|
$settings.playlistsFolder,
|
||||||
|
$settings.musicFolder!
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Playlist saved to: ${m3u8Path}`);
|
||||||
|
// TODO: Show success notification
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading playlist:', err);
|
||||||
|
// TODO: Show error notification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimestamp(timestamp: number | null): string {
|
function formatTimestamp(timestamp: number | null): string {
|
||||||
if (!timestamp) return 'Never';
|
if (!timestamp) return 'Never';
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
@@ -159,116 +294,19 @@
|
|||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="error" style="padding: 8px;">{error}</p>
|
<p class="error" style="padding: 8px;">{error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Header -->
|
<DeezerCollectionView
|
||||||
<div class="collection-header">
|
title={playlistTitle}
|
||||||
{#if playlistPicture}
|
subtitle={playlistCreator}
|
||||||
<img
|
metadata="{playlistTrackCount} track{playlistTrackCount !== 1 ? 's' : ''}"
|
||||||
src={playlistPicture}
|
coverImageUrl={playlistPicture}
|
||||||
alt="{playlistTitle} cover"
|
{tracks}
|
||||||
class="collection-cover"
|
{trackExistsMap}
|
||||||
/>
|
{selectedTrackIndex}
|
||||||
{:else}
|
onTrackClick={handleTrackClick}
|
||||||
<div class="collection-cover-placeholder"></div>
|
onDownloadTrack={handleDownloadTrack}
|
||||||
{/if}
|
onDownloadPlaylist={handleDownloadPlaylist}
|
||||||
<div class="collection-info">
|
{downloadingTrackIds}
|
||||||
<h2>{playlistTitle}</h2>
|
/>
|
||||||
<p class="collection-subtitle">by {playlistCreator}</p>
|
|
||||||
<p class="collection-metadata">{playlistTrackCount} track{playlistTrackCount !== 1 ? 's' : ''}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="collection-content">
|
|
||||||
<!-- Tabs -->
|
|
||||||
<!--
|
|
||||||
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
|
||||||
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
|
||||||
-->
|
|
||||||
<menu role="tablist">
|
|
||||||
<li role="tab" aria-selected={viewMode === 'tracks'}>
|
|
||||||
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
|
|
||||||
</li>
|
|
||||||
<li role="tab" aria-selected={viewMode === 'info'}>
|
|
||||||
<button onclick={() => viewMode = 'info'}>Info</button>
|
|
||||||
</li>
|
|
||||||
</menu>
|
|
||||||
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="window tab-content" role="tabpanel">
|
|
||||||
<div class="window-body">
|
|
||||||
{#if viewMode === 'tracks'}
|
|
||||||
<!-- Track Listing -->
|
|
||||||
<div class="sunken-panel table-container">
|
|
||||||
<table class="interactive">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 50px;">#</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Artist</th>
|
|
||||||
<th>Album</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each tracks as track, i}
|
|
||||||
<tr
|
|
||||||
class:highlighted={selectedTrackIndex === i}
|
|
||||||
onclick={() => handleTrackClick(i)}
|
|
||||||
>
|
|
||||||
<td class="track-number">
|
|
||||||
{track.metadata.trackNumber ?? i + 1}
|
|
||||||
</td>
|
|
||||||
<td>{track.metadata.title || track.filename}</td>
|
|
||||||
<td>{track.metadata.artist || '—'}</td>
|
|
||||||
<td>{track.metadata.album || '—'}</td>
|
|
||||||
<td class="duration">
|
|
||||||
{#if track.metadata.duration}
|
|
||||||
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
|
|
||||||
{:else}
|
|
||||||
—
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{:else if viewMode === 'info'}
|
|
||||||
<!-- Playlist Info -->
|
|
||||||
<div class="info-container">
|
|
||||||
<fieldset>
|
|
||||||
<legend>Playlist Information</legend>
|
|
||||||
<div class="field-row">
|
|
||||||
<span class="field-label">Title:</span>
|
|
||||||
<span>{playlistTitle}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field-row">
|
|
||||||
<span class="field-label">Creator:</span>
|
|
||||||
<span>{playlistCreator}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field-row">
|
|
||||||
<span class="field-label">Tracks:</span>
|
|
||||||
<span>{playlistTrackCount}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field-row">
|
|
||||||
<span class="field-label">Last Updated:</span>
|
|
||||||
<span>{formatTimestamp(lastCached)}</span>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
{#if !isFavoriteTracks}
|
|
||||||
<fieldset style="margin-top: 16px;">
|
|
||||||
<legend>Actions</legend>
|
|
||||||
<button onclick={refreshPlaylistTracks} disabled={refreshing}>
|
|
||||||
{refreshing ? 'Refreshing...' : 'Refresh Playlist'}
|
|
||||||
</button>
|
|
||||||
<p class="help-text">Fetch the latest tracks from Deezer</p>
|
|
||||||
</fieldset>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -282,122 +320,4 @@
|
|||||||
.error {
|
.error {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collection-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 8px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-cover {
|
|
||||||
width: 152px;
|
|
||||||
height: 152px;
|
|
||||||
object-fit: cover;
|
|
||||||
image-rendering: auto;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-cover-placeholder {
|
|
||||||
width: 152px;
|
|
||||||
height: 152px;
|
|
||||||
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
|
||||||
background-size: 8px 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 4px 0;
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-subtitle {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
font-size: 1.1em;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-metadata {
|
|
||||||
margin: 0;
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-content {
|
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
margin-top: -2px;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-body {
|
|
||||||
padding: 0;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-number {
|
|
||||||
text-align: center;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
text-align: center;
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-container {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-label {
|
|
||||||
font-weight: bold;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-text {
|
|
||||||
margin: 8px 0 0 0;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #808080;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
/>
|
/>
|
||||||
<label for="deezer-overwrite">Overwrite existing files</label>
|
<label for="deezer-overwrite">Overwrite existing files</label>
|
||||||
</div>
|
</div>
|
||||||
|
<small class="help-text">When disabled, tracks that already exist will not be added to the download queue</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|||||||
Reference in New Issue
Block a user