mirror of
https://github.com/markuryy/shark.git
synced 2025-12-15 12: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:
@@ -11,6 +11,7 @@
|
||||
let { children } = $props();
|
||||
|
||||
let playlists = $state<Playlist[]>([]);
|
||||
let playlistsLoadTimestamp = $state<number>(0);
|
||||
|
||||
// Count active downloads (queued or downloading)
|
||||
let activeDownloads = $derived(
|
||||
@@ -20,12 +21,25 @@
|
||||
}).length
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings();
|
||||
await loadPlaylists();
|
||||
onMount(() => {
|
||||
// Run async initialization
|
||||
(async () => {
|
||||
await loadSettings();
|
||||
await loadPlaylists();
|
||||
})();
|
||||
|
||||
// Start background queue processor
|
||||
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() {
|
||||
@@ -35,10 +49,37 @@
|
||||
|
||||
try {
|
||||
playlists = await scanPlaylists($settings.playlistsFolder);
|
||||
playlistsLoadTimestamp = Date.now();
|
||||
} catch (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>
|
||||
|
||||
<div class="app-container">
|
||||
@@ -67,22 +108,22 @@
|
||||
Services
|
||||
</summary>
|
||||
<div class="nav-submenu">
|
||||
<a href="/services/spotify" class="nav-item nav-subitem">
|
||||
<!-- <a href="/services/spotify" class="nav-item nav-subitem">
|
||||
<img src="/icons/spotify.png" alt="" class="nav-icon" />
|
||||
Spotify
|
||||
</a>
|
||||
</a> -->
|
||||
<a href="/services/deezer" class="nav-item nav-subitem">
|
||||
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
||||
Deezer
|
||||
</a>
|
||||
<a href="/services/soulseek" class="nav-item nav-subitem">
|
||||
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
|
||||
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
||||
Soulseek
|
||||
</a>
|
||||
<a href="/services/musicbrainz" class="nav-item nav-subitem">
|
||||
</a> -->
|
||||
<!-- <a href="/services/musicbrainz" class="nav-item nav-subitem">
|
||||
<img src="/icons/musicbrainz.svg" alt="" class="nav-icon" />
|
||||
MusicBrainz
|
||||
</a>
|
||||
</a> -->
|
||||
</div>
|
||||
</details>
|
||||
<details class="nav-collapsible" open>
|
||||
|
||||
@@ -17,7 +17,15 @@
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings();
|
||||
await loadPlaylist();
|
||||
});
|
||||
|
||||
// Reactive effect: reload playlist when name changes
|
||||
$effect(() => {
|
||||
// Track the dependency
|
||||
playlistName;
|
||||
|
||||
// Load the playlist
|
||||
loadPlaylist();
|
||||
});
|
||||
|
||||
async function loadPlaylist() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { deezerAuth } from '$lib/stores/deezer';
|
||||
import { deezerAPI } from '$lib/services/deezer';
|
||||
import { settings } from '$lib/stores/settings';
|
||||
import {
|
||||
getCachedPlaylist,
|
||||
getCachedPlaylistTracks,
|
||||
@@ -10,24 +11,30 @@
|
||||
upsertPlaylistTracks,
|
||||
type DeezerPlaylistTrack
|
||||
} 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 { DeezerTrack } from '$lib/types/deezer';
|
||||
|
||||
type ViewMode = 'tracks' | 'info';
|
||||
|
||||
let viewMode = $state<ViewMode>('tracks');
|
||||
let playlistId = $derived($page.params.id ?? '');
|
||||
let playlistTitle = $state('');
|
||||
let playlistCreator = $state('');
|
||||
let playlistTrackCount = $state(0);
|
||||
let playlistPicture = $state<string | undefined>(undefined);
|
||||
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 refreshing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedTrackIndex = $state<number | null>(null);
|
||||
let lastCached = $state<number | null>(null);
|
||||
let downloadingTrackIds = $state<Set<string>>(new Set());
|
||||
|
||||
const isFavoriteTracks = $derived(playlistId === 'favorite-tracks');
|
||||
const existenceCache = new TrackExistenceCache();
|
||||
|
||||
onMount(async () => {
|
||||
await loadPlaylist();
|
||||
@@ -45,7 +52,9 @@
|
||||
|
||||
const favTracks = await getCachedTracks();
|
||||
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;
|
||||
} else {
|
||||
// Load regular playlist
|
||||
@@ -70,9 +79,12 @@
|
||||
if (cachedTracks.length === 0) {
|
||||
await refreshPlaylistTracks();
|
||||
} else {
|
||||
tracks = cachedTracks.map(convertPlaylistTrackToTrack);
|
||||
tracks = cachedTracks.map(convertDbTrackToTrack);
|
||||
}
|
||||
}
|
||||
|
||||
// Check track existence after loading tracks
|
||||
await checkTrackExistence();
|
||||
} catch (e) {
|
||||
error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e));
|
||||
} 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() {
|
||||
if (!$deezerAuth.arl || refreshing) {
|
||||
return;
|
||||
@@ -101,9 +164,12 @@
|
||||
|
||||
// Reload from cache
|
||||
const cachedTracks = await getCachedPlaylistTracks(playlistId);
|
||||
tracks = cachedTracks.map(convertPlaylistTrackToTrack);
|
||||
tracks = cachedTracks.map(convertDbTrackToTrack);
|
||||
lastCached = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Re-check track existence
|
||||
await checkTrackExistence();
|
||||
|
||||
console.log('[Deezer Playlist] Refresh complete!');
|
||||
} catch (e) {
|
||||
console.error('Error refreshing playlist tracks:', e);
|
||||
@@ -113,39 +179,108 @@
|
||||
}
|
||||
}
|
||||
|
||||
function convertPlaylistTrackToTrack(deezerTrack: DeezerPlaylistTrack): Track {
|
||||
return {
|
||||
path: '', // Deezer tracks don't have a local path
|
||||
filename: deezerTrack.title,
|
||||
/**
|
||||
* Convert database playlist track to Track format with Deezer ID attached
|
||||
*/
|
||||
function convertDbTrackToTrack(dbTrack: DeezerPlaylistTrack): Track {
|
||||
const track: Track = {
|
||||
path: '',
|
||||
filename: dbTrack.title,
|
||||
format: 'unknown',
|
||||
metadata: {
|
||||
title: deezerTrack.title,
|
||||
artist: deezerTrack.artist_name,
|
||||
album: deezerTrack.album_title || undefined,
|
||||
trackNumber: deezerTrack.track_number || undefined,
|
||||
duration: deezerTrack.duration
|
||||
title: dbTrack.title,
|
||||
artist: dbTrack.artist_name,
|
||||
album: dbTrack.album_title || undefined,
|
||||
trackNumber: dbTrack.track_number || undefined,
|
||||
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: '',
|
||||
filename: deezerTrack.title,
|
||||
filename: dbTrack.title,
|
||||
format: 'unknown',
|
||||
metadata: {
|
||||
title: deezerTrack.title,
|
||||
artist: deezerTrack.artist_name,
|
||||
album: deezerTrack.album_title || undefined,
|
||||
duration: deezerTrack.duration
|
||||
title: dbTrack.title,
|
||||
artist: dbTrack.artist_name,
|
||||
album: dbTrack.album_title || undefined,
|
||||
duration: dbTrack.duration
|
||||
}
|
||||
};
|
||||
|
||||
// Attach Deezer ID for existence checking and downloading
|
||||
(track as any).deezerId = dbTrack.id;
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
function handleTrackClick(index: number) {
|
||||
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 {
|
||||
if (!timestamp) return 'Never';
|
||||
const date = new Date(timestamp * 1000);
|
||||
@@ -159,116 +294,19 @@
|
||||
{:else if error}
|
||||
<p class="error" style="padding: 8px;">{error}</p>
|
||||
{:else}
|
||||
<!-- Header -->
|
||||
<div class="collection-header">
|
||||
{#if playlistPicture}
|
||||
<img
|
||||
src={playlistPicture}
|
||||
alt="{playlistTitle} cover"
|
||||
class="collection-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="collection-cover-placeholder"></div>
|
||||
{/if}
|
||||
<div class="collection-info">
|
||||
<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>
|
||||
<DeezerCollectionView
|
||||
title={playlistTitle}
|
||||
subtitle={playlistCreator}
|
||||
metadata="{playlistTrackCount} track{playlistTrackCount !== 1 ? 's' : ''}"
|
||||
coverImageUrl={playlistPicture}
|
||||
{tracks}
|
||||
{trackExistsMap}
|
||||
{selectedTrackIndex}
|
||||
onTrackClick={handleTrackClick}
|
||||
onDownloadTrack={handleDownloadTrack}
|
||||
onDownloadPlaylist={handleDownloadPlaylist}
|
||||
{downloadingTrackIds}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -282,122 +320,4 @@
|
||||
.error {
|
||||
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>
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
/>
|
||||
<label for="deezer-overwrite">Overwrite existing files</label>
|
||||
</div>
|
||||
<small class="help-text">When disabled, tracks that already exist will not be added to the download queue</small>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
|
||||
Reference in New Issue
Block a user