848 lines
28 KiB
TypeScript
848 lines
28 KiB
TypeScript
import { useState, useRef } from "react";
|
|
import { downloadTrack } from "@/lib/api";
|
|
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
|
import { logger } from "@/lib/logger";
|
|
import type { TrackMetadata } from "@/types/api";
|
|
|
|
export function useDownload() {
|
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
|
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
|
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
|
const [failedTracks, setFailedTracks] = useState<Set<string>>(new Set());
|
|
const [skippedTracks, setSkippedTracks] = useState<Set<string>>(new Set());
|
|
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
|
|
name: string;
|
|
artists: string;
|
|
} | null>(null);
|
|
const shouldStopDownloadRef = useRef(false);
|
|
|
|
const downloadWithAutoFallback = async (
|
|
isrc: string,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
settings: any,
|
|
trackName?: string,
|
|
artistName?: string,
|
|
albumName?: string,
|
|
playlistName?: string,
|
|
position?: number,
|
|
spotifyId?: string,
|
|
durationMs?: number,
|
|
releaseYear?: string
|
|
) => {
|
|
const service = settings.downloader;
|
|
|
|
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
|
const os = settings.operatingSystem;
|
|
|
|
let outputDir = settings.downloadPath;
|
|
let useAlbumTrackNumber = false;
|
|
|
|
// Build template data for folder path
|
|
const templateData: TemplateData = {
|
|
artist: artistName,
|
|
album: albumName,
|
|
title: trackName,
|
|
track: position,
|
|
year: releaseYear,
|
|
playlist: playlistName,
|
|
isrc: isrc,
|
|
};
|
|
|
|
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
|
if (playlistName) {
|
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
|
}
|
|
|
|
// Apply folder template if available
|
|
if (settings.folderTemplate) {
|
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
|
if (folderPath) {
|
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
|
for (const part of parts) {
|
|
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
|
}
|
|
}
|
|
|
|
// Use album track number if template contains {album}
|
|
if (settings.folderTemplate.includes("{album}")) {
|
|
useAlbumTrackNumber = true;
|
|
}
|
|
}
|
|
|
|
// Always add item to queue before downloading
|
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
|
const itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
|
|
|
|
if (service === "auto") {
|
|
// Get all streaming URLs once from song.link API
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let streamingURLs: any = null;
|
|
if (spotifyId) {
|
|
try {
|
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
|
const urlsJson = await GetStreamingURLs(spotifyId);
|
|
streamingURLs = JSON.parse(urlsJson);
|
|
} catch (err) {
|
|
console.error("Failed to get streaming URLs:", err);
|
|
}
|
|
}
|
|
|
|
// Convert duration from ms to seconds for backend
|
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
|
|
|
// Try Tidal first
|
|
if (streamingURLs?.tidal_url) {
|
|
try {
|
|
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
|
const tidalResponse = await downloadTrack({
|
|
isrc,
|
|
service: "tidal",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
service_url: streamingURLs.tidal_url,
|
|
duration: durationSeconds,
|
|
item_id: itemID, // Pass the same itemID through all attempts
|
|
audio_format: settings.tidalQuality || "LOSSLESS", // Use default LOSSLESS for auto mode
|
|
});
|
|
|
|
if (tidalResponse.success) {
|
|
logger.success(`tidal: ${trackName} - ${artistName}`);
|
|
return tidalResponse;
|
|
}
|
|
logger.warning(`tidal failed, trying deezer...`);
|
|
} catch (tidalErr) {
|
|
logger.error(`tidal error: ${tidalErr}`);
|
|
}
|
|
}
|
|
|
|
// Try Deezer second
|
|
if (streamingURLs?.deezer_url) {
|
|
try {
|
|
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
|
|
const deezerResponse = await downloadTrack({
|
|
isrc,
|
|
service: "deezer",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
service_url: streamingURLs.deezer_url,
|
|
item_id: itemID,
|
|
});
|
|
|
|
if (deezerResponse.success) {
|
|
logger.success(`deezer: ${trackName} - ${artistName}`);
|
|
return deezerResponse;
|
|
}
|
|
logger.warning(`deezer failed, trying amazon...`);
|
|
} catch (deezerErr) {
|
|
logger.error(`deezer error: ${deezerErr}`);
|
|
}
|
|
}
|
|
|
|
// Try Amazon third
|
|
if (streamingURLs?.amazon_url) {
|
|
try {
|
|
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
|
const amazonResponse = await downloadTrack({
|
|
isrc,
|
|
service: "amazon",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
service_url: streamingURLs.amazon_url,
|
|
item_id: itemID,
|
|
});
|
|
|
|
if (amazonResponse.success) {
|
|
logger.success(`amazon: ${trackName} - ${artistName}`);
|
|
return amazonResponse;
|
|
}
|
|
logger.warning(`amazon failed, trying qobuz...`);
|
|
} catch (amazonErr) {
|
|
logger.error(`amazon error: ${amazonErr}`);
|
|
}
|
|
}
|
|
|
|
// Try Qobuz as last fallback
|
|
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
|
|
const qobuzResponse = await downloadTrack({
|
|
isrc,
|
|
service: "qobuz",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
|
item_id: itemID,
|
|
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
|
|
});
|
|
|
|
// If Qobuz also failed, mark the item as failed
|
|
if (!qobuzResponse.success) {
|
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
|
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
|
|
}
|
|
|
|
return qobuzResponse;
|
|
}
|
|
|
|
// Single service download (not auto-fallback)
|
|
// Convert duration from ms to seconds for backend
|
|
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
|
|
|
// Determine audio format based on service
|
|
let audioFormat: string | undefined;
|
|
if (service === "tidal") {
|
|
audioFormat = settings.tidalQuality || "LOSSLESS";
|
|
} else if (service === "qobuz") {
|
|
audioFormat = settings.qobuzQuality || "6";
|
|
}
|
|
|
|
const singleServiceResponse = await downloadTrack({
|
|
isrc,
|
|
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
duration: durationSecondsForFallback,
|
|
item_id: itemID, // Pass itemID for tracking
|
|
audio_format: audioFormat,
|
|
});
|
|
|
|
// Mark as failed if download failed for single-service attempt
|
|
if (!singleServiceResponse.success) {
|
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
|
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
|
|
}
|
|
|
|
return singleServiceResponse;
|
|
};
|
|
|
|
const downloadWithItemID = async (
|
|
isrc: string,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
settings: any,
|
|
itemID: string,
|
|
trackName?: string,
|
|
artistName?: string,
|
|
albumName?: string,
|
|
folderName?: string,
|
|
position?: number,
|
|
spotifyId?: string,
|
|
durationMs?: number,
|
|
isAlbum?: boolean,
|
|
releaseYear?: string
|
|
) => {
|
|
const service = settings.downloader;
|
|
|
|
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
|
const os = settings.operatingSystem;
|
|
|
|
let outputDir = settings.downloadPath;
|
|
let useAlbumTrackNumber = false;
|
|
|
|
// Build template data for folder path
|
|
const templateData: TemplateData = {
|
|
artist: artistName,
|
|
album: albumName,
|
|
title: trackName,
|
|
track: position,
|
|
year: releaseYear,
|
|
playlist: folderName,
|
|
isrc: isrc,
|
|
};
|
|
|
|
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
|
if (folderName && !isAlbum) {
|
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName, os));
|
|
}
|
|
|
|
// Apply folder template if available
|
|
if (settings.folderTemplate) {
|
|
// Parse and apply folder template
|
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
|
if (folderPath) {
|
|
// Split by / and sanitize each part
|
|
const parts = folderPath.split("/").filter(p => p.trim());
|
|
for (const part of parts) {
|
|
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
|
}
|
|
}
|
|
|
|
// Use album track number if template contains {album}
|
|
if (settings.folderTemplate.includes("{album}")) {
|
|
useAlbumTrackNumber = true;
|
|
}
|
|
}
|
|
|
|
if (service === "auto") {
|
|
// Get all streaming URLs once from song.link API
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let streamingURLs: any = null;
|
|
if (spotifyId) {
|
|
try {
|
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
|
const urlsJson = await GetStreamingURLs(spotifyId);
|
|
streamingURLs = JSON.parse(urlsJson);
|
|
} catch (err) {
|
|
console.error("Failed to get streaming URLs:", err);
|
|
}
|
|
}
|
|
|
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
|
|
|
// Try Tidal first
|
|
if (streamingURLs?.tidal_url) {
|
|
try {
|
|
const tidalResponse = await downloadTrack({
|
|
isrc,
|
|
service: "tidal",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
service_url: streamingURLs.tidal_url,
|
|
duration: durationSeconds,
|
|
item_id: itemID,
|
|
audio_format: settings.tidalQuality || "LOSSLESS", // Use default LOSSLESS for auto mode
|
|
});
|
|
|
|
if (tidalResponse.success) {
|
|
return tidalResponse;
|
|
}
|
|
} catch (tidalErr) {
|
|
console.error("Tidal error:", tidalErr);
|
|
}
|
|
}
|
|
|
|
// Try Deezer second
|
|
if (streamingURLs?.deezer_url) {
|
|
try {
|
|
const deezerResponse = await downloadTrack({
|
|
isrc,
|
|
service: "deezer",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
service_url: streamingURLs.deezer_url,
|
|
item_id: itemID,
|
|
});
|
|
|
|
if (deezerResponse.success) {
|
|
return deezerResponse;
|
|
}
|
|
} catch (deezerErr) {
|
|
console.error("Deezer error:", deezerErr);
|
|
}
|
|
}
|
|
|
|
// Try Amazon third
|
|
if (streamingURLs?.amazon_url) {
|
|
try {
|
|
const amazonResponse = await downloadTrack({
|
|
isrc,
|
|
service: "amazon",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
service_url: streamingURLs.amazon_url,
|
|
item_id: itemID,
|
|
});
|
|
|
|
if (amazonResponse.success) {
|
|
return amazonResponse;
|
|
}
|
|
} catch (amazonErr) {
|
|
console.error("Amazon error:", amazonErr);
|
|
}
|
|
}
|
|
|
|
// Try Qobuz as last fallback
|
|
const qobuzResponse = await downloadTrack({
|
|
isrc,
|
|
service: "qobuz",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
|
item_id: itemID,
|
|
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
|
|
});
|
|
|
|
// If Qobuz also failed, mark the item as failed
|
|
if (!qobuzResponse.success) {
|
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
|
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
|
|
}
|
|
|
|
return qobuzResponse;
|
|
}
|
|
|
|
// Single service download
|
|
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
|
|
|
// Determine audio format based on service
|
|
let audioFormat: string | undefined;
|
|
if (service === "tidal") {
|
|
audioFormat = settings.tidalQuality || "LOSSLESS";
|
|
} else if (service === "qobuz") {
|
|
audioFormat = settings.qobuzQuality || "6";
|
|
}
|
|
|
|
const singleServiceResponse = await downloadTrack({
|
|
isrc,
|
|
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
|
query,
|
|
track_name: trackName,
|
|
artist_name: artistName,
|
|
album_name: albumName,
|
|
output_dir: outputDir,
|
|
filename_format: settings.filenameTemplate,
|
|
track_number: settings.trackNumber,
|
|
position,
|
|
use_album_track_number: useAlbumTrackNumber,
|
|
spotify_id: spotifyId,
|
|
embed_lyrics: settings.embedLyrics,
|
|
duration: durationSecondsForFallback,
|
|
item_id: itemID,
|
|
audio_format: audioFormat,
|
|
});
|
|
|
|
// Mark as failed if download failed for single-service attempt
|
|
if (!singleServiceResponse.success) {
|
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
|
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
|
|
}
|
|
|
|
return singleServiceResponse;
|
|
};
|
|
|
|
const handleDownloadTrack = async (
|
|
isrc: string,
|
|
trackName?: string,
|
|
artistName?: string,
|
|
albumName?: string,
|
|
spotifyId?: string,
|
|
playlistName?: string,
|
|
durationMs?: number,
|
|
position?: number
|
|
) => {
|
|
if (!isrc) {
|
|
toast.error("No ISRC found for this track");
|
|
return;
|
|
}
|
|
|
|
logger.info(`starting download: ${trackName} - ${artistName}`);
|
|
const settings = getSettings();
|
|
setDownloadingTrack(isrc);
|
|
|
|
try {
|
|
// Single track download - use playlistName if provided for folder structure
|
|
const response = await downloadWithAutoFallback(
|
|
isrc,
|
|
settings,
|
|
trackName,
|
|
artistName,
|
|
albumName,
|
|
playlistName,
|
|
position, // Pass position for track numbering
|
|
spotifyId,
|
|
durationMs
|
|
);
|
|
|
|
if (response.success) {
|
|
if (response.already_exists) {
|
|
toast.info(response.message);
|
|
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
|
} else {
|
|
toast.success(response.message);
|
|
}
|
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
|
setFailedTracks((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(isrc);
|
|
return newSet;
|
|
});
|
|
} else {
|
|
toast.error(response.error || "Download failed");
|
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Download failed");
|
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
|
} finally {
|
|
setDownloadingTrack(null);
|
|
}
|
|
};
|
|
|
|
const handleDownloadSelected = async (
|
|
selectedTracks: string[],
|
|
allTracks: TrackMetadata[],
|
|
folderName?: string,
|
|
isAlbum?: boolean
|
|
) => {
|
|
if (selectedTracks.length === 0) {
|
|
toast.error("No tracks selected");
|
|
return;
|
|
}
|
|
|
|
logger.info(`starting batch download: ${selectedTracks.length} selected tracks`);
|
|
const settings = getSettings();
|
|
setIsDownloading(true);
|
|
setBulkDownloadType("selected");
|
|
setDownloadProgress(0);
|
|
|
|
// Pre-add ALL tracks to the queue before starting downloads
|
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
|
const itemIDs: string[] = [];
|
|
for (const isrc of selectedTracks) {
|
|
const track = allTracks.find((t) => t.isrc === isrc);
|
|
const itemID = await AddToDownloadQueue(
|
|
isrc,
|
|
track?.name || "",
|
|
track?.artists || "",
|
|
track?.album_name || ""
|
|
);
|
|
itemIDs.push(itemID);
|
|
}
|
|
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
let skippedCount = 0;
|
|
const total = selectedTracks.length;
|
|
|
|
for (let i = 0; i < selectedTracks.length; i++) {
|
|
if (shouldStopDownloadRef.current) {
|
|
toast.info(
|
|
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
|
|
);
|
|
break;
|
|
}
|
|
|
|
const isrc = selectedTracks[i];
|
|
const track = allTracks.find((t) => t.isrc === isrc);
|
|
const itemID = itemIDs[i];
|
|
|
|
setDownloadingTrack(isrc);
|
|
|
|
if (track) {
|
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
|
}
|
|
|
|
try {
|
|
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
|
|
const releaseYear = track?.release_date?.substring(0, 4);
|
|
|
|
// Download with pre-created itemID
|
|
const response = await downloadWithItemID(
|
|
isrc,
|
|
settings,
|
|
itemID,
|
|
track?.name,
|
|
track?.artists,
|
|
track?.album_name,
|
|
folderName,
|
|
i + 1, // Sequential position based on selection order
|
|
track?.spotify_id,
|
|
track?.duration_ms,
|
|
isAlbum,
|
|
releaseYear
|
|
);
|
|
|
|
if (response.success) {
|
|
if (response.already_exists) {
|
|
skippedCount++;
|
|
logger.info(`skipped: ${track?.name} - ${track?.artists} (already exists)`);
|
|
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
|
} else {
|
|
successCount++;
|
|
logger.success(`downloaded: ${track?.name} - ${track?.artists}`);
|
|
}
|
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
|
setFailedTracks((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(isrc); // Remove from failed if it was there
|
|
return newSet;
|
|
});
|
|
} else {
|
|
errorCount++;
|
|
logger.error(`failed: ${track?.name} - ${track?.artists}`);
|
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
|
}
|
|
} catch (err) {
|
|
errorCount++;
|
|
logger.error(`error: ${track?.name} - ${err}`);
|
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
|
// Mark item as failed in queue
|
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
|
}
|
|
|
|
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
|
}
|
|
|
|
setDownloadingTrack(null);
|
|
setCurrentDownloadInfo(null);
|
|
setIsDownloading(false);
|
|
setBulkDownloadType(null);
|
|
shouldStopDownloadRef.current = false;
|
|
|
|
// Cancel any remaining queued items
|
|
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
|
await CancelAllQueuedItems();
|
|
|
|
// Build summary message
|
|
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
|
if (errorCount === 0 && skippedCount === 0) {
|
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
|
} else if (errorCount === 0 && successCount === 0) {
|
|
// All skipped
|
|
toast.info(`${skippedCount} tracks already exist`);
|
|
} else if (errorCount === 0) {
|
|
// Mix of downloaded and skipped
|
|
toast.info(`${successCount} downloaded, ${skippedCount} skipped`);
|
|
} else {
|
|
// Has errors
|
|
const parts = [];
|
|
if (successCount > 0) parts.push(`${successCount} downloaded`);
|
|
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
|
|
parts.push(`${errorCount} failed`);
|
|
toast.warning(parts.join(", "));
|
|
}
|
|
};
|
|
|
|
const handleDownloadAll = async (
|
|
tracks: TrackMetadata[],
|
|
folderName?: string,
|
|
isAlbum?: boolean
|
|
) => {
|
|
const tracksWithIsrc = tracks.filter((track) => track.isrc);
|
|
|
|
if (tracksWithIsrc.length === 0) {
|
|
toast.error("No tracks available for download");
|
|
return;
|
|
}
|
|
|
|
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`);
|
|
const settings = getSettings();
|
|
setIsDownloading(true);
|
|
setBulkDownloadType("all");
|
|
setDownloadProgress(0);
|
|
|
|
// Pre-add ALL tracks to the queue before starting downloads
|
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
|
const itemIDs: string[] = [];
|
|
for (const track of tracksWithIsrc) {
|
|
const itemID = await AddToDownloadQueue(
|
|
track.isrc,
|
|
track.name,
|
|
track.artists,
|
|
track.album_name || ""
|
|
);
|
|
itemIDs.push(itemID);
|
|
}
|
|
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
let skippedCount = 0;
|
|
const total = tracksWithIsrc.length;
|
|
|
|
for (let i = 0; i < tracksWithIsrc.length; i++) {
|
|
if (shouldStopDownloadRef.current) {
|
|
toast.info(
|
|
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
|
|
);
|
|
break;
|
|
}
|
|
|
|
const track = tracksWithIsrc[i];
|
|
const itemID = itemIDs[i];
|
|
|
|
setDownloadingTrack(track.isrc);
|
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
|
|
|
try {
|
|
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
|
|
const releaseYear = track.release_date?.substring(0, 4);
|
|
|
|
const response = await downloadWithItemID(
|
|
track.isrc,
|
|
settings,
|
|
itemID,
|
|
track.name,
|
|
track.artists,
|
|
track.album_name,
|
|
folderName,
|
|
i + 1,
|
|
track.spotify_id,
|
|
track.duration_ms,
|
|
isAlbum,
|
|
releaseYear
|
|
);
|
|
|
|
if (response.success) {
|
|
if (response.already_exists) {
|
|
skippedCount++;
|
|
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
|
|
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
|
|
} else {
|
|
successCount++;
|
|
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
|
}
|
|
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
|
setFailedTracks((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(track.isrc); // Remove from failed if it was there
|
|
return newSet;
|
|
});
|
|
} else {
|
|
errorCount++;
|
|
logger.error(`failed: ${track.name} - ${track.artists}`);
|
|
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
|
}
|
|
} catch (err) {
|
|
errorCount++;
|
|
logger.error(`error: ${track.name} - ${err}`);
|
|
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
|
// Mark item as failed in queue
|
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
|
}
|
|
|
|
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
|
}
|
|
|
|
setDownloadingTrack(null);
|
|
setCurrentDownloadInfo(null);
|
|
setIsDownloading(false);
|
|
setBulkDownloadType(null);
|
|
shouldStopDownloadRef.current = false;
|
|
|
|
// Cancel any remaining queued items
|
|
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
|
await CancelQueued();
|
|
|
|
// Build summary message
|
|
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
|
if (errorCount === 0 && skippedCount === 0) {
|
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
|
} else if (errorCount === 0 && successCount === 0) {
|
|
// All skipped
|
|
toast.info(`${skippedCount} tracks already exist`);
|
|
} else if (errorCount === 0) {
|
|
// Mix of downloaded and skipped
|
|
toast.info(`${successCount} downloaded, ${skippedCount} skipped`);
|
|
} else {
|
|
// Has errors
|
|
const parts = [];
|
|
if (successCount > 0) parts.push(`${successCount} downloaded`);
|
|
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
|
|
parts.push(`${errorCount} failed`);
|
|
toast.warning(parts.join(", "));
|
|
}
|
|
};
|
|
|
|
const handleStopDownload = () => {
|
|
logger.info("download stopped by user");
|
|
shouldStopDownloadRef.current = true;
|
|
toast.info("Stopping download...");
|
|
};
|
|
|
|
const resetDownloadedTracks = () => {
|
|
setDownloadedTracks(new Set());
|
|
setFailedTracks(new Set());
|
|
setSkippedTracks(new Set());
|
|
};
|
|
|
|
return {
|
|
downloadProgress,
|
|
isDownloading,
|
|
downloadingTrack,
|
|
bulkDownloadType,
|
|
downloadedTracks,
|
|
failedTracks,
|
|
skippedTracks,
|
|
currentDownloadInfo,
|
|
handleDownloadTrack,
|
|
handleDownloadSelected,
|
|
handleDownloadAll,
|
|
handleStopDownload,
|
|
resetDownloadedTracks,
|
|
};
|
|
}
|