This commit is contained in:
afkarxyz
2025-11-24 14:52:47 +07:00
parent 73d8205f6f
commit 6ee3c2f653
22 changed files with 865 additions and 253 deletions
+5 -1
View File
@@ -40,7 +40,7 @@ function App() {
const [hasUpdate, setHasUpdate] = useState(false);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.0";
const CURRENT_VERSION = "6.1";
const download = useDownload();
const metadata = useMetadata();
@@ -144,6 +144,7 @@ function App() {
isDownloading={download.isDownloading}
downloadingTrack={download.downloadingTrack}
isDownloaded={download.downloadedTracks.has(track.isrc)}
isFailed={download.failedTracks.has(track.isrc)}
onDownload={download.handleDownloadTrack}
onOpenFolder={handleOpenFolder}
/>
@@ -160,6 +161,7 @@ function App() {
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
@@ -193,6 +195,7 @@ function App() {
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
@@ -231,6 +234,7 @@ function App() {
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
+3
View File
@@ -20,6 +20,7 @@ interface AlbumInfoProps {
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
@@ -46,6 +47,7 @@ export function AlbumInfo({
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
@@ -145,6 +147,7 @@ export function AlbumInfo({
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
+3
View File
@@ -27,6 +27,7 @@ interface ArtistInfoProps {
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
@@ -55,6 +56,7 @@ export function ArtistInfo({
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
@@ -197,6 +199,7 @@ export function ArtistInfo({
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
+3
View File
@@ -26,6 +26,7 @@ interface PlaylistInfoProps {
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
@@ -52,6 +53,7 @@ export function PlaylistInfo({
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
@@ -151,6 +153,7 @@ export function PlaylistInfo({
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
+46 -51
View File
@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Search, Info, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
@@ -19,56 +18,52 @@ interface SearchBarProps {
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
return (
<Card>
<CardContent className="px-6 py-6 space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<InputWithContext
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
<Spinner />
Fetching...
</>
) : (
<>
<Search className="h-4 w-4" />
Fetch
</>
)}
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<InputWithContext
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
</CardContent>
</Card>
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
<Spinner />
Fetching...
</>
) : (
<>
<Search className="h-4 w-4" />
Fetch
</>
)}
</Button>
</div>
</div>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function TitleBar() {
/>
{/* Window control buttons */}
<div className="absolute top-4 left-4 z-50 flex gap-2">
<div className="fixed top-4 left-4 z-50 flex gap-2">
<button
onClick={handleClose}
onMouseEnter={() => setHoveredButton("close")}
+12 -2
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, CheckCircle, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import type { TrackMetadata } from "@/types/api";
@@ -9,6 +9,7 @@ interface TrackInfoProps {
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
isFailed: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onOpenFolder: () => void;
}
@@ -18,6 +19,7 @@ export function TrackInfo({
isDownloading,
downloadingTrack,
isDownloaded,
isFailed,
onDownload,
onOpenFolder,
}: TrackInfoProps) {
@@ -34,7 +36,15 @@ export function TrackInfo({
)}
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isDownloaded && (
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
)}
{isFailed && (
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
)}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
+6 -1
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle } from "lucide-react";
import { Download, CheckCircle, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Pagination,
@@ -18,6 +18,7 @@ interface TrackListProps {
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
currentPage: number;
@@ -36,6 +37,7 @@ export function TrackList({
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
downloadingTrack,
isDownloading,
currentPage,
@@ -165,6 +167,9 @@ export function TrackList({
{downloadedTracks.has(track.isrc) && (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
)}
{failedTracks.has(track.isrc) && (
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
)}
</div>
<span className="text-sm text-muted-foreground">
{track.artists}
+147 -44
View File
@@ -11,6 +11,7 @@ export function useDownload() {
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 [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
name: string;
artists: string;
@@ -57,50 +58,100 @@ export function useDownload() {
}
if (service === "auto") {
// Try Tidal first
try {
const tidalResponse = await downloadTrack({
isrc,
service: "tidal",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
});
if (tidalResponse.success) {
return tidalResponse;
// Get all streaming URLs once from song.link API
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);
}
}
// 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.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.tidal_url,
});
if (tidalResponse.success) {
return tidalResponse;
}
console.log("Tidal failed, trying Deezer...");
} catch (tidalErr) {
console.log("Tidal error:", tidalErr);
}
} catch (tidalErr) {
// Tidal failed, continue to Deezer
}
// Try Deezer second
try {
const deezerResponse = await downloadTrack({
isrc,
service: "deezer",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
});
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.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.deezer_url,
});
if (deezerResponse.success) {
return deezerResponse;
if (deezerResponse.success) {
return deezerResponse;
}
console.log("Deezer failed, trying Amazon...");
} catch (deezerErr) {
console.log("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.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.amazon_url,
});
if (amazonResponse.success) {
return amazonResponse;
}
console.log("Amazon failed, trying Qobuz...");
} catch (amazonErr) {
console.log("Amazon error:", amazonErr);
}
} catch (deezerErr) {
// Deezer failed, continue to Qobuz
}
// Try Qobuz as last fallback
@@ -187,6 +238,7 @@ export function useDownload() {
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
const total = selectedTracks.length;
for (let i = 0; i < selectedTracks.length; i++) {
@@ -221,13 +273,25 @@ export function useDownload() {
);
if (response.success) {
successCount++;
if (response.already_exists) {
skippedCount++;
console.log(`Skipped: ${track?.name} - ${track?.artists} (already exists)`);
} else {
successCount++;
}
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++;
setFailedTracks((prev) => new Set(prev).add(isrc));
}
} catch (err) {
errorCount++;
setFailedTracks((prev) => new Set(prev).add(isrc));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -239,10 +303,22 @@ export function useDownload() {
setBulkDownloadType(null);
shouldStopDownloadRef.current = false;
if (errorCount === 0) {
// Build summary message
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 {
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
// 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(", "));
}
};
@@ -265,6 +341,7 @@ export function useDownload() {
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
const total = tracksWithIsrc.length;
for (let i = 0; i < tracksWithIsrc.length; i++) {
@@ -294,13 +371,25 @@ export function useDownload() {
);
if (response.success) {
successCount++;
if (response.already_exists) {
skippedCount++;
console.log(`Skipped: ${track.name} - ${track.artists} (already exists)`);
} else {
successCount++;
}
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++;
setFailedTracks((prev) => new Set(prev).add(track.isrc));
}
} catch (err) {
errorCount++;
setFailedTracks((prev) => new Set(prev).add(track.isrc));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -312,10 +401,22 @@ export function useDownload() {
setBulkDownloadType(null);
shouldStopDownloadRef.current = false;
if (errorCount === 0) {
// Build summary message
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 {
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
// 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(", "));
}
};
@@ -326,6 +427,7 @@ export function useDownload() {
const resetDownloadedTracks = () => {
setDownloadedTracks(new Set());
setFailedTracks(new Set());
};
return {
@@ -334,6 +436,7 @@ export function useDownload() {
downloadingTrack,
bulkDownloadType,
downloadedTracks,
failedTracks,
currentDownloadInfo,
handleDownloadTrack,
handleDownloadSelected,
+1
View File
@@ -112,6 +112,7 @@ export interface DownloadRequest {
position?: number;
use_album_track_number?: boolean;
spotify_id?: string;
service_url?: string;
}
export interface DownloadResponse {