v7.1.8
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 && (<>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -40,3 +40,6 @@ export function buildClickableArtists(artists: string, artistsData?: ArtistSimpl
|
||||
};
|
||||
});
|
||||
}
|
||||
export function getClickableArtistKey(artist: ClickableArtist) {
|
||||
return artist.id || artist.external_urls || artist.name;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user