This commit is contained in:
429Enjoyer
2026-06-09 06:06:52 +07:00
parent 31e9ecac35
commit 954cfe9d4f
53 changed files with 2910 additions and 912 deletions
+2 -2
View File
@@ -14,10 +14,10 @@ async function generateIcon() {
.resize(1024, 1024)
.png()
.toFile(outputPath);
console.log('Icon generated:', outputPath);
console.log('Icon generated:', outputPath);
}
catch (error) {
console.error('Failed to generate icon:', error.message);
console.error('Failed to generate icon:', error.message);
process.exit(1);
}
}
+62 -2
View File
@@ -5,12 +5,14 @@ import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { openExternal } from "@/lib/utils";
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App";
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar";
import { Header } from "@/components/Header";
import { MarkdownLite, extractMarkdownSection } from "@/components/MarkdownLite";
import { SearchBar } from "@/components/SearchBar";
import { TrackInfo } from "@/components/TrackInfo";
import { AlbumInfo } from "@/components/AlbumInfo";
@@ -22,6 +24,7 @@ import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import { AudioConverterPage } from "@/components/AudioConverterPage";
import { AudioResamplerPage } from "@/components/AudioResamplerPage";
import { FileManagerPage } from "@/components/FileManagerPage";
import { LyricsManagerPage } from "@/components/LyricsManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import { OtherProjects } from "@/components/OtherProjects";
@@ -134,6 +137,12 @@ function App() {
const [currentListPage, setCurrentListPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<{
version: string;
changelog: string;
url: string;
} | null>(null);
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false);
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
@@ -238,14 +247,24 @@ function App() {
}, [metadata.metadata]);
const checkForUpdates = async () => {
try {
const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest");
const response = await fetch("https://api.github.com/repos/spotbye/SpotiFLAC/releases/latest");
const data = await response.json();
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
const rawTag = data.tag_name || "";
const latestVersion = rawTag.replace(/^v/, "") || "";
if (data.published_at) {
setReleaseDate(data.published_at);
}
if (latestVersion && latestVersion > CURRENT_VERSION) {
setHasUpdate(true);
setUpdateInfo({
version: latestVersion,
changelog: extractMarkdownSection(data.body || "", "Changelog"),
url: `https://github.com/spotbye/SpotiFLAC/releases/tag/${rawTag}`,
});
const dismissedVersion = localStorage.getItem("spotiflac_update_dismissed_version");
if (dismissedVersion !== latestVersion) {
setShowUpdateDialog(true);
}
}
}
catch (err) {
@@ -363,6 +382,7 @@ function App() {
name: track.name,
artist: track.artists,
image: track.images,
is_explicit: track.is_explicit,
};
}
else if ("album_info" in metadata.metadata) {
@@ -373,6 +393,7 @@ function App() {
name: album_info.name,
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
image: album_info.images,
is_explicit: album_info.is_explicit,
};
}
else if ("playlist_info" in metadata.metadata) {
@@ -546,6 +567,8 @@ function App() {
return <AudioResamplerPage />;
case "file-manager":
return <FileManagerPage />;
case "lyrics-manager":
return <LyricsManagerPage />;
default:
return (<>
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
@@ -626,6 +649,43 @@ function App() {
</Button>)}
<Dialog open={showUpdateDialog} onOpenChange={setShowUpdateDialog}>
<DialogContent className="sm:max-w-125 [&>button]:hidden">
<DialogHeader>
<DialogTitle>Update Available</DialogTitle>
<DialogDescription>
A new version{updateInfo ? ` (v${updateInfo.version})` : ""} is available. You're on v{CURRENT_VERSION}.
</DialogDescription>
</DialogHeader>
{updateInfo?.changelog ? (<div className="max-h-72 overflow-y-auto rounded-md border bg-muted/40 p-3 custom-scrollbar">
<MarkdownLite content={updateInfo.changelog}/>
</div>) : (<p className="text-sm text-muted-foreground">No changelog provided for this release.</p>)}
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="ghost" onClick={() => {
if (updateInfo) {
localStorage.setItem("spotiflac_update_dismissed_version", updateInfo.version);
}
setShowUpdateDialog(false);
}}>
Don't Show
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowUpdateDialog(false)}>
Download Later
</Button>
<Button onClick={() => {
if (updateInfo) {
openExternal(updateInfo.url);
}
setShowUpdateDialog(false);
}}>
Download Now
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
<DialogHeader>
+9 -5
View File
@@ -12,7 +12,7 @@ import { useState } from "react";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { parseTemplate, type TemplateData } from "@/lib/settings";
import { buildClickableArtists, splitArtistNames } from "@/lib/artist-links";
import { buildClickableArtists, splitArtistNames, getClickableArtistKey } from "@/lib/artist-links";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
@@ -21,6 +21,7 @@ interface AlbumInfoProps {
images: string;
release_date: string;
total_tracks: number;
is_explicit?: boolean;
artist_id?: string;
artist_url?: string;
};
@@ -206,18 +207,21 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
</div>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Album</p>
<p className="text-sm font-medium flex items-center gap-2">
{albumInfo.is_explicit && (<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-red-600 text-[10px] text-white" title="Explicit">E</span>)}
<span>Album</span>
</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
{onArtistClick && artist.external_urls ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={getClickableArtistKey(artist)}>
{onArtistClick && artist.external_urls ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{artist.name}
</span>) : (artist.name)}
</button>) : (artist.name)}
{index < clickableAlbumArtists.length - 1 && artistSeparator}
</span>)) : albumInfo.artists}
</span>
+18 -12
View File
@@ -1,8 +1,9 @@
import { Button } from "@/components/ui/button";
import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react";
import { PlugZap, CheckCircle2, Loader2, Wrench, Server } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
import { useApiStatus } from "@/hooks/useApiStatus";
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
import { openExternal } from "@/lib/utils";
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
if (status === "online") {
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
@@ -31,14 +32,25 @@ export function ApiStatusTab() {
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
const isChecking = isCheckingCurrent || isCheckingNext;
const checkAll = () => {
void checkAllCurrent();
void checkAllNext();
};
return (<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
<Button variant="outline" size="sm" onClick={() => void checkAllCurrent()} disabled={isCheckingCurrent} className="gap-2">
{isCheckingCurrent ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => openExternal("https://spotbye.qzz.io")} className="gap-2">
<Server className="h-4 w-4"/>
Details
</Button>
<Button variant="outline" size="sm" onClick={checkAll} disabled={isChecking} className="gap-2">
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
@@ -60,13 +72,7 @@ export function ApiStatusTab() {
<div className="border-t"/>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
<Button variant="outline" size="sm" onClick={() => void checkAllNext()} disabled={isCheckingNext} className="gap-2">
{isCheckingNext ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
+6 -2
View File
@@ -36,6 +36,7 @@ interface ArtistInfoProps {
album_type: string;
external_urls: string;
total_tracks?: number;
is_explicit?: boolean;
}>;
trackList: TrackMetadata[];
searchQuery: string;
@@ -475,7 +476,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Tooltip>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artistInfo.gallery!.map((imageUrl, index) => (<div key={index} className="relative group">
{artistInfo.gallery!.map((imageUrl, index) => (<div key={`${imageUrl}-${index}`} className="relative group">
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
@@ -537,7 +538,10 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</span>
</div>
</div>
<h4 className="font-semibold truncate text-sm">{album.name}</h4>
<h4 className="font-semibold truncate text-sm flex items-center gap-2">
{album.is_explicit && (<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-red-600 text-[10px] text-white" title="Explicit">E</span>)}
<span className="truncate">{album.name}</span>
</h4>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<span>{album.release_date?.split("-")[0]}</span>
{album.total_tracks && (<>
+18 -9
View File
@@ -51,12 +51,12 @@ export function AudioConverterPage() {
}
return [];
});
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a" | "wav" | "aiff" | "opus">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
if (["mp3", "m4a", "wav", "aiff", "opus"].includes(parsed.outputFormat)) {
return parsed.outputFormat;
}
}
@@ -98,7 +98,7 @@ export function AudioConverterPage() {
const [isFullscreen, setIsFullscreen] = useState(false);
const saveState = useCallback((stateToSave: {
files: AudioFile[];
outputFormat: "mp3" | "m4a";
outputFormat: "mp3" | "m4a" | "wav" | "aiff" | "opus";
bitrate: string;
m4aCodec: "aac" | "alac";
}) => {
@@ -116,7 +116,7 @@ export function AudioConverterPage() {
if (files.length === 0)
return;
const allMP3 = files.every((f) => f.format === "mp3");
if (allMP3 && outputFormat !== "m4a") {
if (allMP3 && outputFormat === "mp3") {
setOutputFormat("m4a");
}
const hasFlac = files.some((f) => f.format === "flac");
@@ -375,15 +375,24 @@ export function AudioConverterPage() {
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Format:</Label>
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
if (value && !isFormatDisabled)
setOutputFormat(value as "mp3" | "m4a");
}} disabled={isFormatDisabled}>
if (value)
setOutputFormat(value as "mp3" | "m4a" | "wav" | "aiff" | "opus");
}}>
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
MP3
</ToggleGroupItem>)}
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
<ToggleGroupItem value="m4a" aria-label="M4A">
M4A
</ToggleGroupItem>
<ToggleGroupItem value="opus" aria-label="Opus">
Opus
</ToggleGroupItem>
<ToggleGroupItem value="wav" aria-label="WAV">
WAV
</ToggleGroupItem>
<ToggleGroupItem value="aiff" aria-label="AIFF">
AIFF
</ToggleGroupItem>
</ToggleGroup>
</div>
@@ -399,7 +408,7 @@ export function AudioConverterPage() {
</ToggleGroup>
</div>)}
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
{(outputFormat === "mp3" || outputFormat === "opus" || (outputFormat === "m4a" && m4aCodec === "aac")) && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Bitrate:</Label>
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
if (value)
+12 -5
View File
@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react";
import { StopCircle, Clock } from "lucide-react";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
interface DownloadProgressProps {
progress: number;
remainingCount?: number;
@@ -11,6 +12,9 @@ interface DownloadProgressProps {
onStop: () => void;
}
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
const liveProgress = useDownloadProgress();
const isRateLimited = Boolean(liveProgress.rate_limited) && (liveProgress.rate_limit_secs ?? 0) > 0;
const rateLimitSecs = liveProgress.rate_limit_secs ?? 0;
const clampedProgress = Math.min(100, Math.max(0, progress));
const safeRemainingCount = Math.max(0, remainingCount);
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
@@ -22,11 +26,14 @@ export function DownloadProgress({ progress, remainingCount = 0, currentTrack, o
Stop
</Button>
</div>
<p className="text-xs text-muted-foreground">
{isRateLimited ? (<p className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<Clock className="h-3.5 w-3.5 shrink-0"/>
Rate limited, please wait. Retrying in {rateLimitSecs}s...
</p>) : (<p className="text-xs text-muted-foreground">
{clampedProgress}% {remainingLabel} -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>)}
</div>);
}
+7 -3
View File
@@ -6,6 +6,7 @@ export interface HistoryItem {
name: string;
artist: string;
image: string;
is_explicit?: boolean;
timestamp: number;
}
interface FetchHistoryProps {
@@ -75,9 +76,12 @@ export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps)
</div>)}
</div>
<div className="space-y-0.5">
<p className="text-xs font-medium truncate" title={item.name}>
{item.name}
</p>
<div className="flex items-center gap-1 min-w-0">
{item.is_explicit ? <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded bg-red-600 text-[9px] font-bold text-white" title="Explicit">E</span> : null}
<p className="min-w-0 text-xs font-medium truncate" title={item.name}>
{item.name}
</p>
</div>
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
{item.artist}
</p>
+7 -3
View File
@@ -11,9 +11,13 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return (<div className="relative">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3">
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
SpotiFLAC
<button type="button" className="cursor-pointer rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => window.location.reload()} aria-label="Reload SpotiFLAC">
<img src="/icon.svg" alt="" className="w-12 h-12"/>
</button>
<h1 className="text-4xl font-bold">
<button type="button" className="cursor-pointer rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => window.location.reload()}>
SpotiFLAC
</button>
</h1>
<div className="relative">
<Tooltip>
+5 -1
View File
@@ -75,6 +75,7 @@ interface FetchHistoryItem {
info: string;
image: string;
data: string;
is_explicit?: boolean;
timestamp: number;
}
interface HistoryPageProps {
@@ -566,7 +567,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
{item.type.slice(0, 2).toUpperCase()}
</div>)}
</div>
<span className="font-medium text-sm truncate">{item.name}</span>
<span className="font-medium text-sm truncate flex items-center gap-2">
{item.is_explicit && (<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-red-600 text-[10px] text-white" title="Explicit">E</span>)}
<span className="truncate">{item.name}</span>
</span>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
@@ -0,0 +1,327 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Upload, X, FileText, Trash2, AlertCircle, Music, Clock, Download } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { ReadEmbeddedLyrics, SelectLyricsFiles, ExtractLyricsToLRC } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface LyricsFile {
path: string;
name: string;
format: string;
lyrics: string;
source: string;
synced: boolean;
status: "loading" | "loaded" | "empty" | "error";
error?: string;
}
const SUPPORTED_EXTENSIONS = [".lrc", ".txt", ".flac", ".mp3", ".m4a", ".aac", ".opus", ".ogg"];
function getExtension(path: string): string {
const lower = path.toLowerCase();
const dot = lower.lastIndexOf(".");
return dot >= 0 ? lower.slice(dot) : "";
}
export function LyricsManagerPage() {
const [files, setFiles] = useState<LyricsFile[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [extracting, setExtracting] = useState(false);
useEffect(() => {
const checkFullscreen = () => {
setIsFullscreen(window.innerHeight >= window.screen.height * 0.9);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const addFiles = useCallback(async (paths: string[]) => {
const validPaths = paths.filter((path) => SUPPORTED_EXTENSIONS.includes(getExtension(path)));
if (validPaths.length === 0) {
if (paths.length > 0) {
toast.error("Unsupported files", {
description: "Only LRC and audio files (FLAC, MP3, M4A) are supported.",
});
}
return;
}
const newPaths: string[] = [];
setFiles((prev) => {
const toAdd = validPaths.filter((path) => !prev.some((f) => f.path === path));
newPaths.push(...toAdd);
const entries: LyricsFile[] = toAdd.map((path) => {
const name = path.split(/[/\\]/).pop() || path;
return {
path,
name,
format: getExtension(path).slice(1),
lyrics: "",
source: "",
synced: false,
status: "loading" as const,
};
});
if (entries.length === 0) {
return prev;
}
return [...prev, ...entries];
});
for (const path of newPaths) {
try {
const result = await ReadEmbeddedLyrics(path);
setFiles((prev) => prev.map((f) => {
if (f.path !== path)
return f;
if (result.error) {
return { ...f, status: "empty" as const, error: result.error };
}
return {
...f,
lyrics: result.lyrics,
source: result.source,
synced: result.synced,
status: "loaded" as const,
};
}));
}
catch (err) {
setFiles((prev) => prev.map((f) => f.path === path
? { ...f, status: "error" as const, error: err instanceof Error ? err.message : "Failed to read lyrics" }
: f));
}
}
setSelectedPath((prev) => prev ?? newPaths[0] ?? null);
}, []);
const handleSelectFiles = async () => {
try {
const selected = await SelectLyricsFiles();
if (selected && selected.length > 0) {
addFiles(selected);
}
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select files",
});
}
};
const handleFileDrop = useCallback((_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0)
return;
addFiles(paths);
}, [addFiles]);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const removeFile = (path: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.path !== path);
setSelectedPath((current) => {
if (current !== path)
return current;
return next[0]?.path ?? null;
});
return next;
});
};
const clearFiles = () => {
setFiles([]);
setSelectedPath(null);
};
const selectedFile = files.find((f) => f.path === selectedPath) || null;
const extractFile = async (file: LyricsFile, overwrite: boolean) => {
const result = await ExtractLyricsToLRC(file.path, overwrite);
if (result.success) {
return { ok: true as const, output: result.output_path };
}
if (result.already_exists) {
return { ok: false as const, alreadyExists: true, output: result.output_path };
}
return { ok: false as const, error: result.error || "Failed to extract lyrics" };
};
const handleExtractSelected = async () => {
if (!selectedFile || selectedFile.status !== "loaded")
return;
setExtracting(true);
try {
const result = await extractFile(selectedFile, false);
if (result.ok) {
toast.success("Lyrics extracted", { description: result.output });
}
else if (result.alreadyExists) {
toast.info("LRC already exists", {
description: "A .lrc file with the same name already exists next to this file.",
});
}
else {
toast.error("Extract failed", { description: result.error });
}
}
catch (err) {
toast.error("Extract failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
}
finally {
setExtracting(false);
}
};
const handleExtractAll = async () => {
const extractable = files.filter((f) => f.status === "loaded");
if (extractable.length === 0) {
toast.error("Nothing to extract", {
description: "No files with embedded lyrics are loaded.",
});
return;
}
setExtracting(true);
let success = 0;
let skipped = 0;
let failed = 0;
for (const file of extractable) {
try {
const result = await extractFile(file, false);
if (result.ok)
success++;
else if (result.alreadyExists)
skipped++;
else
failed++;
}
catch {
failed++;
}
}
setExtracting(false);
if (success > 0) {
toast.success("Lyrics extracted", {
description: `${success} file(s) extracted${skipped > 0 ? `, ${skipped} skipped` : ""}${failed > 0 ? `, ${failed} failed` : ""}`,
});
}
else if (skipped > 0 && failed === 0) {
toast.info("Already extracted", {
description: `${skipped} .lrc file(s) already exist.`,
});
}
else {
toast.error("Extract failed", {
description: `${failed} file(s) failed to extract.`,
});
}
};
const embeddedLoadedCount = files.filter((f) => f.status === "loaded" && f.source === "embedded").length;
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Lyrics Manager</h1>
{files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/>
Add Files
</Button>
{embeddedLoadedCount > 0 && (<Button variant="outline" size="sm" onClick={handleExtractAll} disabled={extracting}>
{extracting ? <Spinner className="h-4 w-4"/> : <Download className="h-4 w-4"/>}
Extract All
</Button>)}
<Button variant="outline" size="sm" onClick={clearFiles} disabled={extracting}>
<Trash2 className="h-4 w-4"/>
Clear All
</Button>
</div>)}
</div>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "min-h-[400px]"} ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}} onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}} onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
{files.length === 0 ? (<>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your files here"
: "Drag and drop LRC or audio files here, or click the button below to select"}
</p>
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5"/>
Select Files
</Button>
<p className="text-xs text-muted-foreground mt-4 text-center">
Reads embedded lyrics from FLAC, MP3, M4A, Opus or plain LRC files
</p>
</>) : (<div className="w-full h-full p-4 flex flex-col md:flex-row gap-4 min-h-0">
<div className="md:w-64 shrink-0 flex flex-col gap-2 md:border-r md:pr-4 max-h-48 md:max-h-none overflow-y-auto">
{files.map((file) => {
const isActive = file.path === selectedPath;
return (<button key={file.path} onClick={() => setSelectedPath(file.path)} className={`group flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${isActive ? "border-primary bg-primary/10" : "hover:bg-muted/60"}`}>
{file.status === "loading" ? (<Spinner className="h-4 w-4 shrink-0 text-primary"/>)
: file.status === "error" || file.status === "empty" ? (<AlertCircle className="h-4 w-4 shrink-0 text-destructive"/>)
: (<FileText className="h-4 w-4 shrink-0 text-muted-foreground"/>)}
<div className="flex-1 min-w-0">
<p className="truncate text-xs font-medium">{file.name}</p>
<p className="truncate text-[10px] uppercase text-muted-foreground">{file.format}</p>
</div>
<span role="button" tabIndex={-1} onClick={(e) => { e.stopPropagation(); removeFile(file.path); }} className="opacity-0 group-hover:opacity-100 transition-opacity rounded p-1 hover:bg-muted">
<X className="h-3.5 w-3.5"/>
</span>
</button>);
})}
</div>
<div className="flex-1 min-w-0 flex flex-col min-h-0">
{!selectedFile ? (<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Select a file to view its lyrics
</div>) : selectedFile.status === "loading" ? (<div className="flex-1 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Spinner className="h-4 w-4"/>
Reading lyrics...
</div>) : selectedFile.status === "error" || selectedFile.status === "empty" ? (<div className="flex-1 flex flex-col items-center justify-center gap-2 text-center px-6">
<AlertCircle className="h-8 w-8 text-destructive"/>
<p className="text-sm font-medium">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">{selectedFile.error || "No lyrics found"}</p>
</div>) : (<>
<div className="flex flex-col gap-2 pb-3 border-b shrink-0">
<div className="flex items-center gap-2 min-w-0">
<p className="truncate text-sm font-medium flex-1">{selectedFile.name}</p>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase shrink-0">
{selectedFile.source === "lrc" ? (<><FileText className="h-3 w-3"/> LRC</>) : (<><Music className="h-3 w-3"/> Embedded</>)}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase shrink-0">
<Clock className="h-3 w-3"/>
{selectedFile.synced ? "Synced" : "Plain"}
</span>
</div>
<div className="flex items-center gap-2">
{selectedFile.source === "embedded" && (<Button variant="outline" size="sm" onClick={handleExtractSelected} disabled={extracting}>
{extracting ? <Spinner className="h-4 w-4"/> : <Download className="h-4 w-4"/>}
Extract LRC
</Button>)}
</div>
</div>
<div className="flex-1 overflow-y-auto pt-3 min-h-0">
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-foreground/90">{selectedFile.lyrics}</pre>
</div>
</>)}
</div>
</div>)}
</div>
</div>);
}
+99
View File
@@ -0,0 +1,99 @@
import { Fragment, type ReactNode } from "react";
import { openExternal } from "@/lib/utils";
export function extractMarkdownSection(body: string, heading: string): string {
const text = (body || "").replace(/\r\n/g, "\n");
const lines = text.split("\n");
const target = heading.trim().toLowerCase();
let start = -1;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^#{1,6}\s+(.*)$/);
if (m && m[1].trim().toLowerCase() === target) {
start = i + 1;
break;
}
}
if (start === -1) {
return text.trim();
}
const collected: string[] = [];
for (let i = start; i < lines.length; i++) {
if (/^#{1,6}\s+/.test(lines[i])) {
break;
}
collected.push(lines[i]);
}
return collected.join("\n").trim();
}
function renderInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = [];
const pattern = /\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
let i = 0;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(<Fragment key={`${keyPrefix}-t${i}`}>{text.slice(lastIndex, match.index)}</Fragment>);
}
if (match[1] !== undefined && match[2] !== undefined) {
const label = match[1];
const url = match[2];
nodes.push(<button key={`${keyPrefix}-l${i}`} type="button" onClick={() => openExternal(url)} className="text-primary underline hover:opacity-80 bg-transparent border-none p-0 cursor-pointer">
{label}
</button>);
}
else if (match[3] !== undefined) {
nodes.push(<strong key={`${keyPrefix}-b${i}`} className="font-semibold text-foreground">{match[3]}</strong>);
}
else if (match[4] !== undefined) {
nodes.push(<em key={`${keyPrefix}-i${i}`}>{match[4]}</em>);
}
else if (match[5] !== undefined) {
nodes.push(<code key={`${keyPrefix}-c${i}`} className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{match[5]}</code>);
}
lastIndex = pattern.lastIndex;
i++;
}
if (lastIndex < text.length) {
nodes.push(<Fragment key={`${keyPrefix}-t${i}`}>{text.slice(lastIndex)}</Fragment>);
}
return nodes;
}
export function MarkdownLite({ content }: {
content: string;
}) {
const lines = (content || "").replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let listItems: string[] = [];
let key = 0;
const flushList = () => {
if (listItems.length === 0)
return;
const items = listItems;
listItems = [];
blocks.push(<ul key={`ul-${key++}`} className="list-disc space-y-1 pl-5">
{items.map((item, idx) => (<li key={idx}>{renderInline(item, `li-${key}-${idx}`)}</li>))}
</ul>);
};
for (const raw of lines) {
const line = raw.trimEnd();
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
if (bullet) {
listItems.push(bullet[1]);
continue;
}
flushList();
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
blocks.push(<p key={`h-${key++}`} className="font-semibold text-foreground">
{renderInline(heading[2], `h-${key}`)}
</p>);
continue;
}
if (line.trim() === "") {
continue;
}
blocks.push(<p key={`p-${key++}`}>{renderInline(line, `p-${key}`)}</p>);
}
flushList();
return <div className="space-y-2 text-sm text-muted-foreground">{blocks}</div>;
}
+9 -9
View File
@@ -201,9 +201,9 @@ export function OtherProjects() {
{repoStats["SpotiFLAC-Next"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>)}
@@ -255,9 +255,9 @@ export function OtherProjects() {
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
<div className="flex flex-wrap gap-2 text-xs">
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
backgroundColor: getLangColor(lang) + "20",
color: getLangColor(lang),
}}>
{lang}
</span>))}
</div>
@@ -273,19 +273,19 @@ export function OtherProjects() {
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5"/>{" "}
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
.createdAt)}
.createdAt)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1">
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
.totalDownloads)}
.totalDownloads)}
</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
.latestDownloads)}
.latestDownloads)}
</span>
</div>
</CardContent>)}
+13 -5
View File
@@ -604,14 +604,22 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
{!searchMode && (<>
{showRegionSelector && (<Select value={region} onValueChange={onRegionChange}>
<SelectTrigger className="w-[70px] shrink-0">
<SelectValue placeholder="Region"/>
<SelectTrigger className="w-22.5 shrink-0">
<SelectValue placeholder="Region">
<span className="flex items-center gap-1.5">
<img src={`/assets/flags/${region.toLowerCase()}.svg`} alt={region} className="h-3 w-4 rounded-[1px] object-cover shrink-0"/>
{region}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
{r}{" "}
<span className="text-muted-foreground">
({getRegionName(r)})
<span className="flex items-center gap-1.5">
<img src={`/assets/flags/${r.toLowerCase()}.svg`} alt="" className="h-3 w-4 rounded-[1px] object-cover shrink-0"/>
{r}{" "}
<span className="text-muted-foreground">
({getRegionName(r)})
</span>
</span>
</SelectItem>))}
</SelectContent>
+192 -67
View File
@@ -9,9 +9,9 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink, PlugZap, Download, Tags } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, hasConfiguredCustomTidalApi, sanitizeAutoOrder, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, sanitizeAutoOrder, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI, CheckCustomQobuzAPI } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { openExternal } from "@/lib/utils";
import { ApiStatusTab } from "./ApiStatusTab";
@@ -28,16 +28,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showAddFontDialog, setShowAddFontDialog] = useState(false);
const [showCustomTidalApiDialog, setShowCustomTidalApiDialog] = useState(false);
const [showCustomQobuzApiDialog, setShowCustomQobuzApiDialog] = useState(false);
const [addFontUrl, setAddFontUrl] = useState("");
const [customTidalApiStatus, setCustomTidalApiStatus] = useState<CustomTidalApiStatus>("idle");
const [customQobuzApiStatus, setCustomQobuzApiStatus] = useState<CustomTidalApiStatus>("idle");
const parsedAddFont = parseGoogleFontUrl(addFontUrl);
const fontOptions = getFontOptions(tempSettings.customFonts);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const hasCustomTidalInstanceConfigured = hasConfiguredCustomTidalApi(tempSettings.customTidalApi);
const effectiveDownloader = !hasCustomTidalInstanceConfigured && tempSettings.downloader === "tidal"
? "auto"
: tempSettings.downloader;
const effectiveAutoOrder = sanitizeAutoOrder(tempSettings.autoOrder, hasCustomTidalInstanceConfigured);
const effectiveDownloader = tempSettings.downloader;
const effectiveAutoOrder = sanitizeAutoOrder(tempSettings.autoOrder);
const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
flushSync(() => {
@@ -180,6 +179,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
};
const handleAmazonQualityChange = (value: "16" | "24") => {
setTempSettings((prev) => ({ ...prev, amazonQuality: value }));
};
const handleAutoQualityChange = async (value: "16" | "24") => {
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
};
@@ -196,10 +198,21 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
setTempSettings((prev) => ({
...prev,
customTidalApi: nextSavedState.customTidalApi,
downloader: !hasConfiguredCustomTidalApi(nextSavedState.customTidalApi) && prev.downloader === "tidal"
? nextSavedState.downloader
: prev.downloader,
autoOrder: sanitizeAutoOrder(prev.autoOrder, hasConfiguredCustomTidalApi(nextSavedState.customTidalApi)),
}));
}, []);
const persistCustomQobuzApi = useCallback(async (nextValue: string) => {
const normalizedValue = nextValue.trim().replace(/\/+$/g, "");
const persistedSettings = getSettings();
const nextSavedSettings: SettingsType = {
...persistedSettings,
customQobuzApi: normalizedValue,
};
await saveSettings(nextSavedSettings);
const nextSavedState = getSettings();
setSavedSettings(nextSavedState);
setTempSettings((prev) => ({
...prev,
customQobuzApi: nextSavedState.customQobuzApi,
}));
}, []);
const handleCheckCustomTidalApi = async () => {
@@ -225,6 +238,29 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
toast.error(`Failed to check HiFi API instance: ${error}`);
}
};
const handleCheckCustomQobuzApi = async () => {
const normalizedCustomQobuzApi = (tempSettings.customQobuzApi || "").trim().replace(/\/+$/g, "");
if (!normalizedCustomQobuzApi.startsWith("https://")) {
toast.error("Enter a valid HTTPS Qobuz-DL instance URL");
return;
}
setCustomQobuzApiStatus("checking");
try {
const isOnline = await CheckCustomQobuzAPI(normalizedCustomQobuzApi);
setCustomQobuzApiStatus(isOnline ? "online" : "offline");
if (isOnline) {
toast.success("Qobuz-DL instance is online");
}
else {
toast.error("Qobuz-DL instance is offline");
}
}
catch (error) {
console.error("Failed to check custom Qobuz API:", error);
setCustomQobuzApiStatus("offline");
toast.error(`Failed to check Qobuz-DL instance: ${error}`);
}
};
const [activeTab, setActiveTab] = useState<"general" | "download" | "files" | "metadata" | "status">("general");
return (<div className="space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0">
@@ -364,18 +400,57 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</div>
</div>)}
{activeTab === "download" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-4">
{activeTab === "download" && (<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-8 items-start">
<div className="space-y-4 lg:pr-8 lg:border-r">
<div className="space-y-2">
<Label>Tidal Source</Label>
<div className="flex items-center gap-2 flex-wrap">
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
<TidalIcon />
Add Instance
</Button>
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
{tempSettings.customTidalApi}
</span>)}
<Label htmlFor="link-resolver">Link Resolver</Label>
<div className="flex items-center gap-3 flex-wrap">
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
...prev,
linkResolver: value,
}))}>
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
<SelectValue placeholder="Select a link resolver"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="songlink">
<span className="flex items-center gap-2">
<SonglinkIcon className="h-4 w-4 shrink-0"/>
Songlink
</span>
</SelectItem>
<SelectItem value="songstats">
<span className="flex items-center gap-2">
<SongstatsIcon className="h-4 w-4 shrink-0"/>
Songstats
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowResolverFallback: checked,
}))}/>
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
Allow Resolver Fallback
</Label>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-base font-semibold">Community</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">1 track / 30s</p>
</TooltipContent>
</Tooltip>
</div>
</div>
@@ -391,12 +466,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
{hasCustomTidalInstanceConfigured && (<SelectItem value="tidal">
<span className="flex items-center gap-2">
<TidalIcon />
Tidal
</span>
</SelectItem>)}
<SelectItem value="tidal">
<span className="flex items-center gap-2">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center gap-2">
<QobuzIcon />
@@ -421,8 +496,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<SelectValue />
</SelectTrigger>
<SelectContent className="w-fit min-w-max">
{hasCustomTidalInstanceConfigured && (<>
<SelectItem value="tidal-qobuz-amazon">
<SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
@@ -504,7 +578,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
</>)}
<SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
@@ -553,15 +626,23 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</SelectContent>
</Select>)}
{effectiveDownloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit - 24-bit/44.1kHz - 192kHz
</div>)}
{effectiveDownloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={handleAmazonQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
<SelectItem value="24">24-bit/48kHz - 192kHz</SelectItem>
</SelectContent>
</Select>)}
</div>
{((effectiveDownloader === "tidal" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(effectiveDownloader === "qobuz" &&
tempSettings.qobuzQuality === "27") ||
(effectiveDownloader === "amazon" &&
tempSettings.amazonQuality === "24") ||
(effectiveDownloader === "auto" &&
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
@@ -576,42 +657,34 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</div>
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-base font-semibold">Custom</Label>
</div>
<div className="space-y-2">
<Label htmlFor="link-resolver">Link Resolver</Label>
<div className="flex items-center gap-3 flex-wrap">
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
...prev,
linkResolver: value,
}))}>
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
<SelectValue placeholder="Select a link resolver"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="songlink">
<span className="flex items-center gap-2">
<SonglinkIcon className="h-4 w-4 shrink-0"/>
Songlink
</span>
</SelectItem>
<SelectItem value="songstats">
<span className="flex items-center gap-2">
<SongstatsIcon className="h-4 w-4 shrink-0"/>
Songstats
</span>
</SelectItem>
</SelectContent>
</Select>
<Label>Tidal</Label>
<div className="flex items-center gap-2 flex-wrap">
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
<TidalIcon />
{tempSettings.customTidalApi ? "Change Instance" : "Add Instance"}
</Button>
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
{tempSettings.customTidalApi}
</span>)}
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowResolverFallback: checked,
}))}/>
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
Allow Resolver Fallback
</Label>
<div className="space-y-2">
<Label>Qobuz</Label>
<div className="flex items-center gap-2 flex-wrap">
<Button type="button" variant="outline" onClick={() => setShowCustomQobuzApiDialog(true)} className="gap-2">
<QobuzIcon />
{tempSettings.customQobuzApi ? "Change Instance" : "Add Instance"}
</Button>
{tempSettings.customQobuzApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customQobuzApi}>
{tempSettings.customQobuzApi}
</span>)}
</div>
</div>
</div>
</div>)}
@@ -936,7 +1009,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<div className="flex items-center justify-between gap-3">
<DialogTitle>Tidal Source</DialogTitle>
<button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
How to create your own instance
How do I create one?
<ExternalLink className="h-3 w-3"/>
</button>
</div>
@@ -982,6 +1055,58 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</DialogContent>
</Dialog>
<Dialog open={showCustomQobuzApiDialog} onOpenChange={setShowCustomQobuzApiDialog}>
<DialogContent className="sm:max-w-md [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between gap-3">
<DialogTitle>Qobuz Source</DialogTitle>
<button type="button" onClick={() => openExternal("https://github.com/QobuzDL/Qobuz-DL")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
How do I create one?
<ExternalLink className="h-3 w-3"/>
</button>
</div>
<DialogDescription />
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="custom-qobuz-api">Instance URL</Label>
<div className="flex gap-2">
<Input id="custom-qobuz-api" type="url" value={tempSettings.customQobuzApi || ""} onChange={(e) => {
const nextValue = e.target.value.replace(/\/+$/g, "");
setCustomQobuzApiStatus("idle");
void persistCustomQobuzApi(nextValue);
}} placeholder="https://your-qobuz-dl.example"/>
<Button type="button" variant="outline" className="gap-2" onClick={() => void handleCheckCustomQobuzApi()} disabled={!((tempSettings.customQobuzApi || "").trim().startsWith("https://")) || customQobuzApiStatus === "checking"}>
{customQobuzApiStatus === "checking" ? "Checking..." : <><PlugZap className="h-4 w-4"/>Check</>}
</Button>
{tempSettings.customQobuzApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
setCustomQobuzApiStatus("idle");
void persistCustomQobuzApi("");
}}>
<Trash2 className="h-4 w-4 text-destructive"/>
</Button>)}
</div>
</div>
{customQobuzApiStatus !== "idle" && (<p className={`text-xs ${customQobuzApiStatus === "online"
? "text-green-600 dark:text-green-400"
: customQobuzApiStatus === "offline"
? "text-destructive"
: "text-muted-foreground"}`}>
{customQobuzApiStatus === "online"
? "Custom Qobuz-DL instance is online."
: customQobuzApiStatus === "offline"
? "Custom Qobuz-DL instance is offline or returned no download URL."
: "Checking custom Qobuz-DL instance..."}
</p>)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCustomQobuzApiDialog(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
+8 -2
View File
@@ -6,6 +6,7 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
import { FileTextIcon, type FileTextIconHandle } from "@/components/ui/file-text";
import { BugReportIcon } from "@/components/ui/bug-report-icon";
import { CoffeeIcon } from "@/components/ui/coffee";
import { BlocksIcon } from "@/components/ui/blocks-icon";
@@ -17,7 +18,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "lyrics-manager" | "projects" | "support" | "history";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
@@ -33,6 +34,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
const converterIconRef = useRef<FileMusicIconHandle>(null);
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
const lyricsManagerIconRef = useRef<FileTextIconHandle>(null);
const handleIssuesDialogChange = (open: boolean) => {
setIsIssuesDialogOpen(open);
if (!open) {
@@ -99,7 +101,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}>
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager", "lyrics-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager", "lyrics-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
<ToolCaseIcon size={20}/>
</Button>
</TooltipTrigger>
@@ -125,6 +127,10 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<FilePenIcon ref={fileManagerIconRef} size={16}/>
<span>File Manager</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onPageChange("lyrics-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(lyricsManagerIconRef)}>
<FileTextIcon ref={lyricsManagerIconRef} size={16}/>
<span>Lyrics Manager</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
-1
View File
@@ -7,7 +7,6 @@ import KofiSvg from "@/assets/kofi_symbol.svg";
import PatreonLogo from "@/assets/patreon.svg";
import PatreonSymbol from "@/assets/patreon_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg";
export function SupportPage() {
const [copiedUsdt, setCopiedUsdt] = useState(false);
const [copiedEmail, setCopiedEmail] = useState(false);
+6 -6
View File
@@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { usePreview } from "@/hooks/usePreview";
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
import { buildClickableArtists } from "@/lib/artist-links";
import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links";
interface TrackInfoProps {
track: TrackMetadata & {
album_name: string;
@@ -83,14 +83,14 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div>
<p className="text-lg text-muted-foreground">
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={getClickableArtistKey(artist)}>
{onArtistClick ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{artist.name}
</span>) : (artist.name)}
</button>) : (artist.name)}
{index < clickableArtists.length - 1 && ", "}
</span>)) : track.artists}
</p>
@@ -99,13 +99,13 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{hasAlbumClick ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick?.({
<p className="font-medium truncate">{hasAlbumClick ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-left text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onAlbumClick?.({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})}>
{track.album_name}
</span>) : (track.album_name)}</p>
</button>) : (track.album_name)}</p>
</div>
{track.plays && (<div>
<p className="text-xs text-muted-foreground">Total Plays</p>
+10 -9
View File
@@ -7,7 +7,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { usePreview } from "@/hooks/usePreview";
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
import { buildClickableArtists } from "@/lib/artist-links";
import { buildClickableArtists, getClickableArtistKey } from "@/lib/artist-links";
interface TrackListProps {
tracks: TrackMetadata[];
searchQuery: string;
@@ -55,6 +55,7 @@ interface TrackListProps {
}
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
const getTrackKey = (track: TrackMetadata) => track.spotify_id || track.external_urls || `${track.name}-${track.album_name}-${track.disc_number ?? 1}-${track.track_number}`;
let filteredTracks = tracks.filter((track) => {
if (!searchQuery)
return true;
@@ -219,7 +220,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</tr>
</thead>
<tbody>
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{paginatedTracks.map((track, index) => (<tr key={getTrackKey(track)} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (<td className="p-4 align-middle">
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
</td>)}
@@ -242,9 +243,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
{onTrackClick ? (<button type="button" className="font-medium cursor-pointer rounded-sm bg-transparent p-0 text-left text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onTrackClick(track)}>
{track.name}
</span>) : (<span className="font-medium">{track.name}</span>)}
</button>) : (<span className="font-medium">{track.name}</span>)}
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
@@ -255,14 +256,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
if (clickableArtists.length === 0) {
return track.artists;
}
return clickableArtists.map((artist, i) => (<span key={`${artist.id || artist.name}-${i}`}>
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
return clickableArtists.map((artist, i) => (<span key={getClickableArtistKey(artist)}>
{onArtistClick ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{artist.name}
</span>) : (artist.name)}
</button>) : (artist.name)}
{i < clickableArtists.length - 1 && ", "}
</span>));
})()}
@@ -271,13 +272,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</div>
</td>
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
{onAlbumClick && track.album_id && track.album_url ? (<button type="button" className="cursor-pointer rounded-sm bg-transparent p-0 text-left text-inherit hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" onClick={() => onAlbumClick({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})}>
{track.album_name}
</span>) : (track.album_name)}
</button>) : (track.album_name)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
{formatDuration(track.duration_ms)}
+5 -20
View File
@@ -1,19 +1,14 @@
"use client";
import type { Transition, Variants } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState, type HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type ReportIconMode = "bug" | "bulb";
interface BugReportIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
loop?: boolean;
}
const LOOP_INTERVAL_MS = 2200;
const GROUP_VARIANTS: Variants = {
hidden: {
opacity: 0,
@@ -33,7 +28,6 @@ const GROUP_VARIANTS: Variants = {
},
},
};
const DRAW_VARIANTS: Variants = {
hidden: {
pathLength: 0,
@@ -48,7 +42,6 @@ const DRAW_VARIANTS: Variants = {
opacity: 0,
},
};
function createDrawTransition(delay = 0, duration = 0.36): Transition {
return {
duration,
@@ -57,7 +50,6 @@ function createDrawTransition(delay = 0, duration = 0.36): Transition {
opacity: { delay },
};
}
function BugPaths() {
return (<>
<motion.path d="m8 2 1.88 1.88" transition={createDrawTransition(0)} variants={DRAW_VARIANTS}/>
@@ -73,7 +65,6 @@ function BugPaths() {
<motion.path d="M3 21a4 4 0 0 1 3.81-4" transition={createDrawTransition(0.56)} variants={DRAW_VARIANTS}/>
</>);
}
function BulbPaths() {
return (<>
<motion.path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" transition={createDrawTransition(0, 0.46)} variants={DRAW_VARIANTS}/>
@@ -81,13 +72,13 @@ function BulbPaths() {
<motion.path d="M10 22h4" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
</>);
}
function ReportIconGroup({ mode }: { mode: ReportIconMode }) {
function ReportIconGroup({ mode }: {
mode: ReportIconMode;
}) {
return (<motion.g animate="visible" exit="exit" initial="hidden" variants={GROUP_VARIANTS}>
{mode === "bug" ? <BugPaths/> : <BulbPaths/>}
{mode === "bug" ? <BugPaths /> : <BulbPaths />}
</motion.g>);
}
function StaticBugIcon() {
return (<g>
<path d="m8 2 1.88 1.88"/>
@@ -103,30 +94,24 @@ function StaticBugIcon() {
<path d="M3 21a4 4 0 0 1 3.81-4"/>
</g>);
}
function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) {
const [mode, setMode] = useState<ReportIconMode>("bug");
useEffect(() => {
if (!loop) {
setMode("bug");
return;
}
const intervalId = window.setInterval(() => {
setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug");
}, LOOP_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, [loop]);
return (<div className={cn("flex items-center justify-center", className)} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
{loop ? (<AnimatePresence>
<ReportIconGroup key={mode} mode={mode}/>
</AnimatePresence>) : (<StaticBugIcon/>)}
</AnimatePresence>) : (<StaticBugIcon />)}
</svg>
</div>);
}
export { BugReportIcon };
+65
View File
@@ -0,0 +1,65 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FileTextIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FileTextIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FileTextIcon = forwardRef<FileTextIconHandle, FileTextIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M10 9H8" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M16 13H8" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M16 17H8" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FileTextIcon.displayName = 'FileTextIcon';
export { FileTextIcon };
-11
View File
@@ -4,16 +4,13 @@ import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ToolCaseIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DRAW_VARIANTS: Variants = {
normal: {
pathLength: 1,
@@ -28,7 +25,6 @@ const DRAW_VARIANTS: Variants = {
},
},
};
const HANDLE_VARIANTS: Variants = {
normal: {
scaleX: 1,
@@ -43,11 +39,9 @@ const HANDLE_VARIANTS: Variants = {
},
},
};
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
@@ -55,7 +49,6 @@ const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMous
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
@@ -64,7 +57,6 @@ const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMous
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
@@ -73,7 +65,6 @@ const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMous
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
@@ -83,7 +74,5 @@ const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMous
</svg>
</div>);
});
ToolCaseIcon.displayName = 'ToolCaseIcon';
export { ToolCaseIcon };
+326 -289
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
import { getSettings, hasConfiguredCustomTidalApi, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
import { getSettings, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
import { logger } from "@/lib/logger";
@@ -86,13 +86,15 @@ export function useDownload(region: string) {
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount));
};
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem;
const customTidalApi = allowTidal && typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
? settings.customTidalApi.trim().replace(/\/+$/g, "")
: undefined;
const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://")
? settings.customQobuzApi.trim().replace(/\/+$/g, "")
: undefined;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
const placeholder = "__SLASH_PLACEHOLDER__";
@@ -194,7 +196,303 @@ export function useDownload(region: string) {
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
}
if (service === "auto") {
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
const order = sanitizeAutoOrder(settings.autoOrder).split("-");
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
streamingURLs = JSON.parse(urlsJson);
}
catch (err) {
console.error("Failed to get streaming URLs:", err);
}
}
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
let lastResponse: any = { success: false, error: "No matching services found" };
const fallbackErrors: string[] = [];
const tidalQuality = getTidalAudioFormat(settings, "auto");
const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) {
try {
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs?.tidal_url,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
tidal_api_url: customTidalApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`Tidal: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response;
logger.warning(`Tidal failed, trying next...`);
}
catch (err) {
logger.error(`Tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
else if (s === "amazon" && streamingURLs?.amazon_url) {
try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "amazon",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
audio_format: is24Bit ? "24" : "16",
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Amazon] ${errMsg}`);
lastResponse = response;
logger.warning(`amazon failed, trying next...`);
}
catch (err) {
logger.error(`amazon error: ${err}`);
fallbackErrors.push(`[Amazon] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
else if (s === "qobuz") {
try {
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "qobuz",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position: trackNumberForTemplate,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
item_id: itemID,
audio_format: qobuzQuality,
qobuz_api_url: customQobuzApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Qobuz] ${errMsg}`);
lastResponse = response;
logger.warning(`qobuz failed, trying next...`);
}
catch (err) {
logger.error(`qobuz error: ${err}`);
fallbackErrors.push(`[Qobuz] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
}
if (itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
await MarkDownloadItemFailed(itemID, finalError);
}
return lastResponse;
}
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
let audioFormat: string | undefined;
if (service === "tidal") {
audioFormat = getTidalAudioFormat(settings, "single");
}
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
else if (service === "amazon") {
audioFormat = settings.amazonQuality || "16";
}
else if (service === "deezer") {
audioFormat = "flac";
}
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
const singleServiceResponse = await downloadTrack({
service: service as "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position: trackNumberForTemplate,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
}
return singleServiceResponse;
};
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
? settings.customTidalApi.trim().replace(/\/+$/g, "")
: undefined;
const customQobuzApi = typeof settings.customQobuzApi === "string" && settings.customQobuzApi.trim().startsWith("https://")
? settings.customQobuzApi.trim().replace(/\/+$/g, "")
: undefined;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
const placeholder = "__SLASH_PLACEHOLDER__";
let finalReleaseDate = releaseDate;
let finalTrackNumber = spotifyTrackNumber || 0;
if (spotifyId) {
try {
const trackURL = `https://open.spotify.com/track/${spotifyId}`;
const trackMetadata = await fetchSpotifyMetadata(trackURL, false, 0, 10);
if ("track" in trackMetadata && trackMetadata.track) {
if (trackMetadata.track.release_date) {
finalReleaseDate = trackMetadata.track.release_date;
}
if (trackMetadata.track.track_number > 0) {
finalTrackNumber = trackMetadata.track.track_number;
}
}
}
catch (err) {
}
}
const yearValue = releaseYear || finalReleaseDate?.substring(0, 4);
const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== "";
const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0);
const displayArtist = settings.useFirstArtistOnly && artistName
? getFirstArtist(artistName)
: artistName;
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
? getFirstArtist(albumArtist)
: albumArtist;
const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId);
const templateData: TemplateData = {
artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
isrc: resolvedTemplateISRC?.replace(/\//g, placeholder),
track: trackNumberForTemplate,
year: yearValue,
date: releaseDate,
playlist: folderName?.replace(/\//g, placeholder),
};
const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter(p => p.trim());
for (const part of parts) {
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
if (service === "auto") {
const order = sanitizeAutoOrder(settings.autoOrder).split("-");
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
@@ -264,290 +562,6 @@ export function useDownload(region: string) {
lastResponse = { success: false, error: String(err) };
}
}
else if (s === "amazon" && streamingURLs?.amazon_url) {
try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "amazon",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Amazon] ${errMsg}`);
lastResponse = response;
logger.warning(`amazon failed, trying next...`);
}
catch (err) {
logger.error(`amazon error: ${err}`);
fallbackErrors.push(`[Amazon] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
else if (s === "qobuz") {
try {
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "qobuz",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position: trackNumberForTemplate,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
item_id: itemID,
audio_format: qobuzQuality,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Qobuz] ${errMsg}`);
lastResponse = response;
logger.warning(`qobuz failed, trying next...`);
}
catch (err) {
logger.error(`qobuz error: ${err}`);
fallbackErrors.push(`[Qobuz] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
}
if (itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
await MarkDownloadItemFailed(itemID, finalError);
}
return lastResponse;
}
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
let audioFormat: string | undefined;
if (service === "tidal") {
audioFormat = getTidalAudioFormat(settings, "single");
}
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
else if (service === "deezer") {
audioFormat = "flac";
}
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
const singleServiceResponse = await downloadTrack({
service: service as "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position: trackNumberForTemplate,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
}
return singleServiceResponse;
};
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
const placeholder = "__SLASH_PLACEHOLDER__";
let finalReleaseDate = releaseDate;
let finalTrackNumber = spotifyTrackNumber || 0;
if (spotifyId) {
try {
const trackURL = `https://open.spotify.com/track/${spotifyId}`;
const trackMetadata = await fetchSpotifyMetadata(trackURL, false, 0, 10);
if ("track" in trackMetadata && trackMetadata.track) {
if (trackMetadata.track.release_date) {
finalReleaseDate = trackMetadata.track.release_date;
}
if (trackMetadata.track.track_number > 0) {
finalTrackNumber = trackMetadata.track.track_number;
}
}
}
catch (err) {
}
}
const yearValue = releaseYear || finalReleaseDate?.substring(0, 4);
const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== "";
const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0);
const displayArtist = settings.useFirstArtistOnly && artistName
? getFirstArtist(artistName)
: artistName;
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
? getFirstArtist(albumArtist)
: albumArtist;
const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId);
const templateData: TemplateData = {
artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
isrc: resolvedTemplateISRC?.replace(/\//g, placeholder),
track: trackNumberForTemplate,
year: yearValue,
date: releaseDate,
playlist: folderName?.replace(/\//g, placeholder),
};
const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter(p => p.trim());
for (const part of parts) {
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
if (service === "auto") {
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
streamingURLs = JSON.parse(urlsJson);
}
catch (err) {
console.error("Failed to get streaming URLs:", err);
}
}
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
let lastResponse: any = { success: false, error: "No matching services found" };
const fallbackErrors: string[] = [];
const tidalQuality = getTidalAudioFormat(settings, "auto");
const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) {
try {
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs?.tidal_url,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`Tidal: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response;
logger.warning(`Tidal failed, trying next...`);
}
catch (err) {
logger.error(`Tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
else if (s === "amazon" && streamingURLs?.amazon_url) {
try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
@@ -619,6 +633,7 @@ export function useDownload(region: string) {
duration: durationSeconds,
item_id: itemID,
audio_format: qobuzQuality,
qobuz_api_url: customQobuzApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -661,6 +676,9 @@ export function useDownload(region: string) {
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
else if (service === "amazon") {
audioFormat = settings.amazonQuality || "16";
}
const singleServiceResponse = await downloadTrack({
service: service as "tidal" | "qobuz" | "amazon",
query,
@@ -681,6 +699,8 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
qobuz_api_url: service === "qobuz" ? customQobuzApi : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -834,6 +854,10 @@ export function useDownload(region: string) {
try {
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
if (response.cancelled || shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
break;
}
if (response.success) {
if (response.already_exists) {
skippedCount++;
@@ -1007,6 +1031,10 @@ export function useDownload(region: string) {
try {
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
if (response.cancelled || shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
break;
}
if (response.success) {
if (response.already_exists) {
skippedCount++;
@@ -1085,6 +1113,15 @@ export function useDownload(region: string) {
const handleStopDownload = () => {
logger.info("download stopped by user");
shouldStopDownloadRef.current = true;
void (async () => {
try {
const { ForceStopDownloads } = await import("../../wailsjs/go/main/App");
await ForceStopDownloads();
}
catch (err) {
console.error("Failed to force stop downloads:", err);
}
})();
toast.info("Stopping download...");
};
const resetDownloadedTracks = () => {
@@ -4,12 +4,16 @@ export interface DownloadProgressInfo {
is_downloading: boolean;
mb_downloaded: number;
speed_mbps: number;
rate_limited?: boolean;
rate_limit_secs?: number;
}
export function useDownloadProgress() {
const [progress, setProgress] = useState<DownloadProgressInfo>({
is_downloading: false,
mb_downloaded: 0,
speed_mbps: 0,
rate_limited: false,
rate_limit_secs: 0,
});
const intervalRef = useRef<number | null>(null);
useEffect(() => {
+1
View File
@@ -159,6 +159,7 @@ export function useMetadata() {
info: info,
image: image,
data: jsonStr,
is_explicit: ("track" in data && Boolean(data.track.is_explicit)) || ("album_info" in data && Boolean(data.album_info.is_explicit)),
timestamp: Math.floor(Date.now() / 1000)
});
}
+15
View File
@@ -96,6 +96,21 @@
}
}
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
@theme inline {
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
+36 -146
View File
@@ -1,88 +1,60 @@
import { CheckAPIStatus, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
import { getSettings, hasConfiguredCustomTidalApi } from "@/lib/settings";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource {
id: string;
type: string;
name: string;
url: string;
}
interface SpotiFLACNextSource {
id: string;
name: string;
statusKey?: string;
statusPrefix?: string;
}
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
type ApiStatusTargetReport = {
target?: string;
label?: string;
online?: boolean;
message?: string;
};
type ApiStatusReport = {
type?: string;
online?: boolean;
require_all?: boolean;
details?: ApiStatusTargetReport[];
};
export const API_SOURCES: ApiSource[] = [
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
];
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
{ id: "tidal", name: "Tidal", statusPrefix: "tidal_" },
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
{ id: "apple", name: "Apple Music", statusKey: "apple" },
];
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
const SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY = "amazon_a";
const SPOTIFLAC_CURRENT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/7e392bc94ec2faaf74ef7d80025636eb/raw";
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
const CheckAPIStatusReport = (apiType: string, apiURL: string): Promise<ApiStatusReport> => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL);
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
type ApiStatusState = {
checkingSources: Record<string, boolean>;
statuses: Record<string, ApiCheckStatus>;
nextStatuses: Record<string, ApiCheckStatus>;
};
let apiStatusState: ApiStatusState = {
checkingSources: {},
statuses: {},
nextStatuses: {},
};
let activeCheckCurrentOnly: Promise<void> | null = null;
let activeCheckNextOnly: Promise<void> | null = null;
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
let activeCurrentStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
const activeSourceChecks = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
function emitApiStatusChange() {
for (const listener of listeners) {
listener();
}
}
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
apiStatusState = updater(apiStatusState);
emitApiStatusChange();
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
@@ -94,52 +66,12 @@ function sendStatusConsole(level: "info" | "warning" | "error", message: string)
return;
}
}
function logStatusInfo(message: string): void {
sendStatusConsole("info", message);
}
function logStatusWarning(message: string): void {
sendStatusConsole("warning", message);
}
function logStatusError(message: string): void {
sendStatusConsole("error", message);
}
function truncateStatusMessage(message?: string, maxLen = 180): string {
const trimmed = (message || "").trim();
if (trimmed.length <= maxLen) {
return trimmed;
}
return trimmed.slice(0, maxLen) + "...";
}
function logQobuzStatusReport(report: ApiStatusReport): void {
const details = Array.isArray(report.details) ? report.details : [];
if (details.length === 0) {
logStatusWarning("[Status][Qobuz] No provider details were returned.");
return;
}
const onlineCount = details.filter((detail) => detail.online === true).length;
logStatusInfo(`[Status][Qobuz] Provider check completed: ${onlineCount}/${details.length} providers online.`);
for (const detail of details) {
const label = detail.label || detail.target || "Unknown provider";
const suffix = detail.message ? ` - ${truncateStatusMessage(detail.message)}` : "";
if (detail.online) {
logStatusInfo(`[Status][Qobuz] ${label}: online${suffix}`);
}
else {
logStatusWarning(`[Status][Qobuz] ${label}: offline${suffix}`);
}
}
if (report.online) {
logStatusInfo(`[Status][Qobuz] SpotiFLAC Qobuz is online (${onlineCount}/${details.length} providers online).`);
}
else {
logStatusWarning(`[Status][Qobuz] SpotiFLAC Qobuz marked maintenance because all ${details.length} providers are offline.`);
}
}
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
return values.some((value) => value === "up") ? "online" : "offline";
}
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
if (source.statusKey) {
const value = payload[source.statusKey];
@@ -156,11 +88,6 @@ function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: Spoti
}
return values;
}
function getCurrentAmazonStatus(payload: SpotiFLACNextStatusResponse): ApiCheckStatus {
return payload[SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY] === "up" ? "online" : "offline";
}
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
const current = currentStatuses[source.id];
@@ -168,58 +95,51 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
return acc;
}, {});
}
function hasCurrentResults(): boolean {
return API_SOURCES.some((source) => {
const status = apiStatusState.statuses[source.id];
return status === "online" || status === "offline";
});
}
function hasSpotiFLACNextResults(): boolean {
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
const status = apiStatusState.nextStatuses[source.id];
return status === "online" || status === "offline";
});
}
async function fetchSpotiFLACStatusPayloadOnce(): Promise<SpotiFLACNextStatusResponse> {
const response = await withTimeout(fetch(SPOTIFLAC_STATUS_URL, {
async function fetchStatusPayloadOnce(url: string): Promise<SpotiFLACNextStatusResponse> {
const response = await withTimeout(fetch(url, {
method: "GET",
cache: "no-store",
headers: {
Accept: "application/json",
},
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
if (!response.ok) {
throw new Error(`SpotiFLAC status returned ${response.status}`);
}
return (await response.json()) as SpotiFLACNextStatusResponse;
}
async function fetchStatusPayloadWithRetry(url: string): Promise<SpotiFLACNextStatusResponse> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
try {
return await fetchStatusPayloadOnce(url);
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
}
async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
if (activeStatusPayloadFetch) {
return activeStatusPayloadFetch;
}
activeStatusPayloadFetch = (async () => {
let lastError: unknown = null;
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
try {
return await fetchSpotiFLACStatusPayloadOnce();
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
})();
activeStatusPayloadFetch = fetchStatusPayloadWithRetry(SPOTIFLAC_STATUS_URL);
try {
return await activeStatusPayloadFetch;
}
@@ -227,42 +147,28 @@ async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusRespons
activeStatusPayloadFetch = null;
}
}
async function fetchSpotiFLACCurrentStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
if (activeCurrentStatusPayloadFetch) {
return activeCurrentStatusPayloadFetch;
}
activeCurrentStatusPayloadFetch = fetchStatusPayloadWithRetry(SPOTIFLAC_CURRENT_STATUS_URL);
try {
return await activeCurrentStatusPayloadFetch;
}
finally {
activeCurrentStatusPayloadFetch = null;
}
}
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
try {
if (source.id === "tidal") {
const customTidalApi = getSettings().customTidalApi;
if (!hasConfiguredCustomTidalApi(customTidalApi)) {
logStatusWarning("[Status][Tidal] Marked maintenance because no custom Tidal instance is configured.");
return "offline";
}
const isOnline = await withTimeout(CheckCustomTidalAPI(customTidalApi), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
if (source.id === "amazon") {
const payload = await fetchSpotiFLACStatusPayload();
return getCurrentAmazonStatus(payload);
}
if (source.id === "qobuz") {
logStatusInfo("[Status][Qobuz] Checking current SpotiFLAC providers...");
const report = await withTimeout(CheckAPIStatusReport(source.type, source.url), CHECK_TIMEOUT_MS, `API status report timed out after 10 seconds for ${source.name}`);
logQobuzStatusReport(report);
return report.online ? "online" : "offline";
}
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
const payload = await fetchSpotiFLACCurrentStatusPayload();
return payload[source.id] === "up" ? "online" : "offline";
}
catch (error) {
if (source.id === "qobuz") {
logStatusError(`[Status][Qobuz] Provider check failed: ${error instanceof Error ? error.message : String(error)}`);
}
logStatusError(`[Status][${source.name}] Status check failed: ${error instanceof Error ? error.message : String(error)}`);
return "offline";
}
}
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
const payload = await fetchSpotiFLACStatusPayload();
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
@@ -270,27 +176,22 @@ async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStat
return acc;
}, {});
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
}
export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function checkCurrentApiStatusesOnly(): Promise<void> {
if (activeCheckCurrentOnly) {
return activeCheckCurrentOnly;
}
activeCheckCurrentOnly = (async () => {
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
})();
try {
await activeCheckCurrentOnly;
}
@@ -298,12 +199,10 @@ export async function checkCurrentApiStatusesOnly(): Promise<void> {
activeCheckCurrentOnly = null;
}
}
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
if (activeCheckNextOnly) {
return activeCheckNextOnly;
}
activeCheckNextOnly = (async () => {
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
setApiStatusState((current) => ({
@@ -313,7 +212,6 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
...checkingNextStatuses,
},
}));
try {
const nextStatuses = await checkSpotiFLACNextStatuses();
setApiStatusState((current) => ({
@@ -331,7 +229,6 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
}));
}
})();
try {
await activeCheckNextOnly;
}
@@ -339,7 +236,6 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
activeCheckNextOnly = null;
}
}
export function ensureApiStatusCheckStarted(): void {
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
void checkCurrentApiStatusesOnly();
@@ -348,22 +244,18 @@ export function ensureApiStatusCheckStarted(): void {
void checkSpotiFLACNextStatusesOnly();
}
}
export function ensureSpotiFLACNextStatusCheckStarted(): void {
ensureApiStatusCheckStarted();
}
export async function checkApiStatus(sourceId: string): Promise<void> {
const source = API_SOURCES.find((item) => item.id === sourceId);
if (!source) {
return;
}
const activeCheck = activeSourceChecks.get(sourceId);
if (activeCheck) {
return activeCheck;
}
const task = (async () => {
setApiStatusState((current) => ({
...current,
@@ -376,7 +268,6 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
[sourceId]: "checking",
},
}));
try {
const status = await checkSourceStatus(source);
setApiStatusState((current) => ({
@@ -398,7 +289,6 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
activeSourceChecks.delete(sourceId);
}
})();
activeSourceChecks.set(sourceId, task);
return task;
}
+3
View File
@@ -40,3 +40,6 @@ export function buildClickableArtists(artists: string, artistsData?: ArtistSimpl
};
});
}
export function getClickableArtistKey(artist: ClickableArtist) {
return artist.id || artist.external_urls || artist.name;
}
+25 -17
View File
@@ -21,6 +21,7 @@ export interface Settings {
downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon";
customTidalApi: string;
customQobuzApi: string;
linkResolver: "songstats" | "songlink";
allowResolverFallback: boolean;
theme: string;
@@ -41,7 +42,7 @@ export interface Settings {
operatingSystem: "Windows" | "linux/MacOS";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
amazonQuality: "original";
amazonQuality: "16" | "24";
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
autoQuality: "16" | "24";
allowFallback: boolean;
@@ -167,6 +168,7 @@ export const DEFAULT_SETTINGS: Settings = {
downloadPath: "",
downloader: "auto",
customTidalApi: "",
customQobuzApi: "",
linkResolver: "songlink",
allowResolverFallback: true,
theme: "yellow",
@@ -184,8 +186,8 @@ export const DEFAULT_SETTINGS: Settings = {
operatingSystem: detectOS(),
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "original",
autoOrder: "qobuz-amazon",
amazonQuality: "16",
autoOrder: "tidal-qobuz-amazon",
autoQuality: "16",
allowFallback: true,
createPlaylistFolder: true,
@@ -524,11 +526,17 @@ function normalizeCustomTidalApi(value: unknown): string {
export function hasConfiguredCustomTidalApi(value: unknown): boolean {
return normalizeCustomTidalApi(value).startsWith("https://");
}
export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string {
const allowedServices = allowTidal
? new Set(["tidal", "qobuz", "amazon"])
: new Set(["qobuz", "amazon"]);
const fallbackOrder = allowTidal ? "tidal-qobuz-amazon" : "qobuz-amazon";
function normalizeCustomQobuzApi(value: unknown): string {
return typeof value === "string"
? value.trim().replace(/\/+$/g, "")
: "";
}
export function hasConfiguredCustomQobuzApi(value: unknown): boolean {
return normalizeCustomQobuzApi(value).startsWith("https://");
}
export function sanitizeAutoOrder(order: unknown): string {
const allowedServices = new Set(["tidal", "qobuz", "amazon"]);
const fallbackOrder = "tidal-qobuz-amazon";
if (typeof order !== "string") {
return fallbackOrder;
}
@@ -538,12 +546,9 @@ export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string {
.filter((part, index, parts) => part !== "" && allowedServices.has(part) && parts.indexOf(part) === index);
return normalized.length >= 2 ? normalized.join("-") : fallbackOrder;
}
function normalizeDownloader(value: unknown, allowTidal: boolean): Settings["downloader"] {
function normalizeDownloader(value: unknown): Settings["downloader"] {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "tidal") {
return allowTidal ? "tidal" : "auto";
}
if (normalized === "qobuz" || normalized === "amazon" || normalized === "auto") {
if (normalized === "tidal" || normalized === "qobuz" || normalized === "amazon" || normalized === "auto") {
return normalized;
}
return DEFAULT_SETTINGS.downloader;
@@ -607,7 +612,10 @@ function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
normalized.qobuzQuality = "6";
}
if (!("amazonQuality" in normalized)) {
normalized.amazonQuality = "original";
normalized.amazonQuality = "16";
}
if (normalized.amazonQuality !== "16" && normalized.amazonQuality !== "24") {
normalized.amazonQuality = "16";
}
if (!("autoOrder" in normalized)) {
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
@@ -616,9 +624,9 @@ function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
normalized.autoQuality = "16";
}
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
const allowTidal = hasConfiguredCustomTidalApi(normalized.customTidalApi);
normalized.downloader = normalizeDownloader(normalized.downloader, allowTidal);
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder, allowTidal);
normalized.customQobuzApi = normalizeCustomQobuzApi(normalized.customQobuzApi);
normalized.downloader = normalizeDownloader(normalized.downloader);
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder);
if (!("allowFallback" in normalized)) {
normalized.allowFallback = true;
}
+5 -2
View File
@@ -1,9 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MotionConfig } from "motion/react";
import "./index.css";
import App from "./App.tsx";
import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(<StrictMode>
<App />
<Toaster position="bottom-left" duration={1000}/>
<MotionConfig reducedMotion="user">
<App />
<Toaster position="bottom-left" duration={1000}/>
</MotionConfig>
</StrictMode>);
+4
View File
@@ -40,6 +40,7 @@ export interface AlbumInfo {
release_date: string;
artists: string;
images: string;
is_explicit?: boolean;
upc?: string;
batch?: string;
}
@@ -93,6 +94,7 @@ export interface DiscographyAlbum {
artists: string;
images: string;
external_urls: string;
is_explicit?: boolean;
}
export interface ArtistDiscographyResponse {
artist_info: ArtistInfo;
@@ -120,6 +122,7 @@ export interface DownloadRequest {
release_date?: string;
cover_url?: string;
tidal_api_url?: string;
qobuz_api_url?: string;
output_dir?: string;
audio_format?: string;
folder_name?: string;
@@ -151,6 +154,7 @@ export interface DownloadResponse {
file?: string;
error?: string;
already_exists?: boolean;
cancelled?: boolean;
item_id?: string;
}
export interface HealthResponse {