v7.1.5
This commit is contained in:
+29
-13
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Search, X, ArrowUp } from "lucide-react";
|
||||
@@ -32,7 +32,7 @@ import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useCover } from "@/hooks/useCover";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
|
||||
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
import { buildPlaylistFolderName } from "@/lib/playlist";
|
||||
@@ -125,6 +125,7 @@ function parseStoredHistory(value: string | null): HistoryItem[] {
|
||||
}
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||
const contentScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -197,20 +198,33 @@ function App() {
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
checkForUpdates();
|
||||
ensureApiStatusCheckStarted();
|
||||
ensureSpotiFLACNextStatusCheckStarted();
|
||||
void loadHistory();
|
||||
const handleScroll = () => {
|
||||
setShowScrollTop(window.scrollY > 300);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const contentElement = contentScrollRef.current;
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
const handleScroll = () => {
|
||||
setShowScrollTop(contentElement.scrollTop > 300);
|
||||
};
|
||||
handleScroll();
|
||||
contentElement.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => {
|
||||
contentElement.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
const scrollToTop = useCallback(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
contentScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
contentScrollRef.current?.scrollTo({ top: 0, behavior: "auto" });
|
||||
setShowScrollTop(false);
|
||||
}, [currentPage]);
|
||||
useEffect(() => {
|
||||
setSelectedTracks([]);
|
||||
setSearchQuery("");
|
||||
@@ -584,14 +598,16 @@ function App() {
|
||||
}
|
||||
};
|
||||
return (<TooltipProvider>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<div className="h-screen overflow-hidden bg-background">
|
||||
<TitleBar />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
||||
|
||||
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{renderPage()}
|
||||
<div ref={contentScrollRef} className="fixed top-10 right-0 bottom-0 left-14 overflow-y-auto overflow-x-hidden">
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{renderPage()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #2dc261;
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<g id="Page-1" sketch:type="MSPage">
|
||||
<g id="Icon-Set-Filled" sketch:type="MSLayerGroup">
|
||||
<path id="arrow-right-circle" class="cls-1" d="M350.1,268.7l-81.5,81.5c-5.6,5.6-14.7,5.6-20.4,0-5.6-5.6-5.6-14.8,0-20.5l59.4-59.3h-152.5c-8,0-14.4-6.5-14.4-14.4s6.4-14.4,14.4-14.4h152.5l-59.4-59.3c-5.6-5.6-5.6-14.7,0-20.5,5.6-5.6,14.7-5.6,20.4,0l81.5,81.5c3.5,3.5,4.5,8.2,3.7,12.7.8,4.5-.3,9.2-3.7,12.7h0ZM256,25.6c-127.3,0-230.4,103.1-230.4,230.4s103.2,230.4,230.4,230.4,230.4-103.1,230.4-230.4S383.3,25.6,256,25.6h0Z" sketch:type="MSShapeGroup"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -8,7 +8,6 @@ import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||
import XIcon from "@/assets/x.webp";
|
||||
import XProIcon from "@/assets/x-pro.webp";
|
||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||
import KofiLogo from "@/assets/ko-fi.gif";
|
||||
@@ -21,7 +20,12 @@ const browserExtensionItems = [
|
||||
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
||||
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
|
||||
];
|
||||
const projectCardClass = "cursor-pointer transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
||||
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
||||
const projectCardHeaderClass = "px-5 gap-1.5";
|
||||
const projectCardContentClass = "px-5";
|
||||
const projectBodyClass = "text-[13px] leading-snug";
|
||||
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
|
||||
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
|
||||
export function AboutPage() {
|
||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||
@@ -44,8 +48,7 @@ export function AboutPage() {
|
||||
}
|
||||
}
|
||||
const repos = [
|
||||
{ name: "SpotiDownloader", owner: "afkarxyz" },
|
||||
{ name: "SpotiFLAC-Next", owner: "spotiverse" },
|
||||
{ name: "SpotiFLAC-Next", owner: "spotbye" },
|
||||
{ name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
|
||||
];
|
||||
const stats: Record<string, any> = {};
|
||||
@@ -176,7 +179,7 @@ export function AboutPage() {
|
||||
const getRepoDescription = (repoName: string): string => {
|
||||
return repoStats[repoName]?.description || "";
|
||||
};
|
||||
return (<div className="flex flex-col space-y-4">
|
||||
return (<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||
</div>
|
||||
@@ -195,17 +198,17 @@ export function AboutPage() {
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
|
||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||
<div className="grid gap-2 grid-cols-4">
|
||||
<Card className={`gap-2 ${projectCardClass}`} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
||||
<CardHeader>
|
||||
{activeTab === "projects" && (<div className="pr-1.5">
|
||||
<div className="grid gap-2 grid-cols-3">
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
||||
<CardHeader className={projectCardHeaderClass}>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["SpotiFLAC-Next"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
<div className="ml-3 flex flex-wrap items-center justify-end gap-2">
|
||||
{repoStats["SpotiFLAC-Next"]?.latestReleaseAt && (<span className={releaseMetaClass}>
|
||||
{formatReleaseTimeAgo(repoStats["SpotiFLAC-Next"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className={releaseVersionClass}>
|
||||
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
@@ -213,11 +216,11 @@ export function AboutPage() {
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiFLAC Next
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<CardDescription className={projectBodyClass}>
|
||||
{getRepoDescription("SpotiFLAC-Next")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
|
||||
{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",
|
||||
@@ -245,76 +248,21 @@ export function AboutPage() {
|
||||
<Info className="h-3.5 w-3.5"/>
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
||||
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
||||
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiDownloader")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["SpotiDownloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatReleaseTimeAgo(repoStats["SpotiDownloader"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiDownloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiDownloader
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("SpotiDownloader")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{repoStats["SpotiDownloader"].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),
|
||||
}}>
|
||||
{lang}
|
||||
</span>))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||
{formatNumber(repoStats["SpotiDownloader"].stars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||
{repoStats["SpotiDownloader"].forks}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["SpotiDownloader"].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["SpotiDownloader"].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["SpotiDownloader"].latestDownloads)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||
<CardHeader>
|
||||
<CardHeader className={projectCardHeaderClass}>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
<div className="ml-3 flex flex-wrap items-center justify-end gap-2">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && (<span className={releaseMetaClass}>
|
||||
{formatReleaseTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className={releaseVersionClass}>
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
@@ -322,11 +270,11 @@ export function AboutPage() {
|
||||
<CardTitle className="leading-tight">
|
||||
Twitter/X Media Batch Downloader
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<CardDescription className={projectBodyClass}>
|
||||
{getRepoDescription("Twitter-X-Media-Batch-Downloader")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
|
||||
{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",
|
||||
@@ -364,14 +312,14 @@ export function AboutPage() {
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<div className="flex h-full flex-col gap-1.5">
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||
<CardHeader>
|
||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-2 pt-2">
|
||||
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2">
|
||||
<CardHeader className={projectCardHeaderClass}>
|
||||
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
||||
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
|
||||
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
|
||||
<span className="text-[11px] leading-tight text-muted-foreground">
|
||||
<span className={`${projectBodyClass} text-muted-foreground`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>))}
|
||||
@@ -379,12 +327,12 @@ export function AboutPage() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://spotubedl.com/")}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CardHeader className={projectCardHeaderClass}>
|
||||
<CardTitle className="flex items-center gap-2 leading-tight">
|
||||
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||
SpotubeDL.com
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<CardDescription className={projectBodyClass}>
|
||||
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,34 +1,79 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, LrclibIcon, MusicBrainzIcon } from "./PlatformIcons";
|
||||
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
||||
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
|
||||
if (status === "online") {
|
||||
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
||||
}
|
||||
if (status === "offline") {
|
||||
return <XCircle className="h-5 w-5 text-destructive"/>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function renderPlatformIcon(type: string) {
|
||||
if (type === "tidal") {
|
||||
return <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
if (type === "amazon") {
|
||||
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
if (type === "musicbrainz") {
|
||||
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
if (type === "deezer") {
|
||||
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
if (type === "apple") {
|
||||
return <AppleMusicIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||
}
|
||||
export function ApiStatusTab() {
|
||||
const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus();
|
||||
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" onClick={() => void refreshAll()} disabled={isCheckingAll} className="gap-2">
|
||||
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
||||
Refresh All
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{sources.map((source) => {
|
||||
const status = statuses[source.id] || "idle";
|
||||
const isChecking = checkingSources[source.id] === true;
|
||||
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{renderPlatformIcon(source.type)}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
|
||||
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
|
||||
Check
|
||||
</Button>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{sources.map((source) => {
|
||||
const status = statuses[source.id] || "idle";
|
||||
<div className="border-t"/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</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) => {
|
||||
const status = nextStatuses[source.id] || "idle";
|
||||
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "lrclib" ? <LrclibIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "musicbrainz" ? <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
||||
{renderPlatformIcon(source.id)}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{status === "checking" && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground"/>}
|
||||
{status === "online" && <CheckCircle2 className="h-5 w-5 text-emerald-500"/>}
|
||||
{status === "offline" && <XCircle className="h-5 w-5 text-destructive"/>}
|
||||
{status === "idle" && <div className="h-5 w-5 rounded-full bg-muted"/>}
|
||||
</div>
|
||||
<div className="flex items-center">{renderStatusIcon(status)}</div>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import amazonMusicIcon from "../assets/icons/amzn.png";
|
||||
import appleMusicIcon from "../assets/icons/am.png";
|
||||
import deezerIcon from "../assets/icons/dzr.png";
|
||||
import lrclibIcon from "../assets/icons/lrclib.png";
|
||||
import musicBrainzDarkIcon from "../assets/icons/musicbrainz_d.png";
|
||||
import musicBrainzLightIcon from "../assets/icons/musicbrainz_l.png";
|
||||
@@ -81,6 +83,12 @@ export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={amazonMusicIcon} alt="Amazon Music" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function AppleMusicIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={appleMusicIcon} alt="Apple Music" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function DeezerIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={deezerIcon} alt="Deezer" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function LrclibIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={lrclibIcon} alt="LRCLIB" className={className}/>;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||
};
|
||||
const handleTidalVariantChange = (value: "tidal" | "alt") => {
|
||||
setTempSettings((prev) => ({ ...prev, tidalVariant: value }));
|
||||
};
|
||||
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||
};
|
||||
@@ -424,17 +427,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</Select>
|
||||
</>)}
|
||||
|
||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">
|
||||
24-bit/48kHz
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
{tempSettings.downloader === "tidal" && (tempSettings.tidalVariant === "alt" ? (<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/44.1kHz
|
||||
</div>) : (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">
|
||||
24-bit/48kHz
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>))}
|
||||
|
||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
@@ -452,7 +457,21 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
|
||||
</div>
|
||||
|
||||
{(tempSettings.downloader === "tidal" || tempSettings.downloader === "auto") && (<div className="space-y-2 pt-2">
|
||||
<Label htmlFor="tidal-variant">Tidal Variant</Label>
|
||||
<Select value={tempSettings.tidalVariant || "tidal"} onValueChange={handleTidalVariantChange}>
|
||||
<SelectTrigger id="tidal-variant" className="h-9 w-fit min-w-[160px]">
|
||||
<SelectValue placeholder="Select Tidal variant"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tidal">Tidal</SelectItem>
|
||||
<SelectItem value="alt">Tidal Alt.</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>)}
|
||||
|
||||
{((tempSettings.downloader === "tidal" &&
|
||||
tempSettings.tidalVariant !== "alt" &&
|
||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||
(tempSettings.downloader === "qobuz" &&
|
||||
tempSettings.qobuzQuality === "27") ||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { API_SOURCES, checkAllApiStatuses, ensureApiStatusCheckStarted, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||
import { API_SOURCES, checkApiStatus, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||
export function useApiStatus() {
|
||||
const [state, setState] = useState(getApiStatusState);
|
||||
useEffect(() => {
|
||||
ensureApiStatusCheckStarted();
|
||||
return subscribeApiStatus(() => {
|
||||
setState(getApiStatusState());
|
||||
});
|
||||
@@ -11,6 +10,6 @@ export function useApiStatus() {
|
||||
return {
|
||||
...state,
|
||||
sources: API_SOURCES,
|
||||
refreshAll: () => checkAllApiStatuses(true),
|
||||
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,24 @@ async function resolveTemplateISRC(settings: {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function getTidalVariant(settings: any): "tidal" | "alt" {
|
||||
return settings?.tidalVariant === "alt" ? "alt" : "tidal";
|
||||
}
|
||||
function isTidalAltVariant(settings: any): boolean {
|
||||
return getTidalVariant(settings) === "alt";
|
||||
}
|
||||
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
|
||||
if (isTidalAltVariant(settings)) {
|
||||
return "LOSSLESS";
|
||||
}
|
||||
if (mode === "auto") {
|
||||
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
}
|
||||
return settings.tidalQuality || "LOSSLESS";
|
||||
}
|
||||
function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
|
||||
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
|
||||
}
|
||||
export function useDownload(region: string) {
|
||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
@@ -170,8 +188,11 @@ export function useDownload(region: string) {
|
||||
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
||||
}
|
||||
if (service === "auto") {
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
const tidalVariant = getTidalVariant(settings);
|
||||
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId) {
|
||||
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||
@@ -182,16 +203,15 @@ export function useDownload(region: string) {
|
||||
}
|
||||
}
|
||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
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 tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
const qobuzQuality = is24Bit ? "27" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||
try {
|
||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "tidal",
|
||||
query,
|
||||
@@ -209,7 +229,8 @@ export function useDownload(region: string) {
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: streamingURLs.tidal_url,
|
||||
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||
tidal_variant: tidalVariant,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: tidalQuality,
|
||||
@@ -225,17 +246,17 @@ export function useDownload(region: string) {
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`tidal failed, trying next...`);
|
||||
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`tidal error: ${err}`);
|
||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||
logger.error(`${tidalLabel} error: ${err}`);
|
||||
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -344,7 +365,7 @@ export function useDownload(region: string) {
|
||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
let audioFormat: string | undefined;
|
||||
if (service === "tidal") {
|
||||
audioFormat = settings.tidalQuality || "LOSSLESS";
|
||||
audioFormat = getTidalAudioFormat(settings, "single");
|
||||
}
|
||||
else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
@@ -373,6 +394,7 @@ export function useDownload(region: string) {
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID,
|
||||
audio_format: audioFormat,
|
||||
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -380,6 +402,7 @@ export function useDownload(region: string) {
|
||||
isrc: resolvedTemplateISRC || undefined,
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
@@ -451,8 +474,11 @@ export function useDownload(region: string) {
|
||||
}
|
||||
}
|
||||
if (service === "auto") {
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
const tidalVariant = getTidalVariant(settings);
|
||||
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId) {
|
||||
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||
@@ -463,16 +489,15 @@ export function useDownload(region: string) {
|
||||
}
|
||||
}
|
||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
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 tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
const qobuzQuality = is24Bit ? "27" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||
try {
|
||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "tidal",
|
||||
query,
|
||||
@@ -490,7 +515,8 @@ export function useDownload(region: string) {
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: streamingURLs.tidal_url,
|
||||
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||
tidal_variant: tidalVariant,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: tidalQuality,
|
||||
@@ -506,17 +532,17 @@ export function useDownload(region: string) {
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`tidal failed, trying next...`);
|
||||
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`tidal error: ${err}`);
|
||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||
logger.error(`${tidalLabel} error: ${err}`);
|
||||
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -628,7 +654,7 @@ export function useDownload(region: string) {
|
||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
let audioFormat: string | undefined;
|
||||
if (service === "tidal") {
|
||||
audioFormat = settings.tidalQuality || "LOSSLESS";
|
||||
audioFormat = getTidalAudioFormat(settings, "single");
|
||||
}
|
||||
else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
@@ -653,6 +679,7 @@ export function useDownload(region: string) {
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID,
|
||||
audio_format: audioFormat,
|
||||
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
@@ -265,4 +272,4 @@
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
+172
-105
@@ -1,4 +1,4 @@
|
||||
import { CheckAPIStatus, FetchUnifiedAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
||||
export interface ApiSource {
|
||||
@@ -7,39 +7,51 @@ export interface ApiSource {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
export const API_SOURCES: ApiSource[] = [
|
||||
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
|
||||
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
|
||||
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
||||
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
||||
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
||||
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
||||
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
||||
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" },
|
||||
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" },
|
||||
{ id: "lrclib", type: "lrclib", name: "LRCLIB", url: "https://lrclib.net" },
|
||||
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
||||
];
|
||||
type ApiStatusState = {
|
||||
isCheckingAll: boolean;
|
||||
statuses: Record<string, ApiCheckStatus>;
|
||||
};
|
||||
let apiStatusState: ApiStatusState = {
|
||||
isCheckingAll: false,
|
||||
statuses: {},
|
||||
};
|
||||
let activeCheckAll: Promise<void> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
type SpotiFLACUnifiedStatusResponse = {
|
||||
interface SpotiFLACNextSource {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
type SpotiFLACNextStatusResponse = {
|
||||
tidal?: string;
|
||||
qobuz_a?: string;
|
||||
qobuz_b?: string;
|
||||
qobuz_c?: string;
|
||||
amazon?: string;
|
||||
lrclib?: string;
|
||||
deezer_a?: string;
|
||||
deezer_b?: string;
|
||||
amazon_a?: string;
|
||||
amazon_b?: string;
|
||||
amazon_c?: string;
|
||||
apple?: string;
|
||||
};
|
||||
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: "" },
|
||||
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
||||
];
|
||||
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||
{ id: "tidal", name: "Tidal" },
|
||||
{ id: "qobuz", name: "Qobuz" },
|
||||
{ id: "amazon", name: "Amazon Music" },
|
||||
{ id: "deezer", name: "Deezer" },
|
||||
{ id: "apple", name: "Apple Music" },
|
||||
];
|
||||
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
|
||||
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
||||
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
||||
type ApiStatusState = {
|
||||
checkingSources: Record<string, boolean>;
|
||||
statuses: Record<string, ApiCheckStatus>;
|
||||
nextStatuses: Record<string, ApiCheckStatus>;
|
||||
};
|
||||
let apiStatusState: ApiStatusState = {
|
||||
checkingSources: {},
|
||||
statuses: {},
|
||||
nextStatuses: {},
|
||||
};
|
||||
let activeCheckNextOnly: Promise<void> | null = null;
|
||||
const activeSourceChecks = new Map<string, Promise<void>>();
|
||||
const listeners = new Set<() => void>();
|
||||
function emitApiStatusChange() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
@@ -49,39 +61,66 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState)
|
||||
apiStatusState = updater(apiStatusState);
|
||||
emitApiStatusChange();
|
||||
}
|
||||
function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus {
|
||||
return value === "up" ? "online" : "offline";
|
||||
}
|
||||
async function fetchUnifiedStatuses(forceRefresh: boolean): Promise<Pick<ApiStatusState, "statuses">> {
|
||||
const response = await FetchUnifiedAPIStatus(forceRefresh);
|
||||
const payload = JSON.parse(response) as SpotiFLACUnifiedStatusResponse;
|
||||
const tidalStatus = statusFromUnifiedValue(payload.tidal);
|
||||
return {
|
||||
statuses: {
|
||||
tidal1: tidalStatus,
|
||||
tidal2: tidalStatus,
|
||||
tidal3: tidalStatus,
|
||||
tidal4: tidalStatus,
|
||||
tidal5: tidalStatus,
|
||||
tidal6: tidalStatus,
|
||||
tidal7: tidalStatus,
|
||||
qobuz1: statusFromUnifiedValue(payload.qobuz_a),
|
||||
qobuz2: statusFromUnifiedValue(payload.qobuz_b),
|
||||
qobuz3: statusFromUnifiedValue(payload.qobuz_c),
|
||||
amazon1: statusFromUnifiedValue(payload.amazon),
|
||||
lrclib: statusFromUnifiedValue(payload.lrclib),
|
||||
},
|
||||
};
|
||||
}
|
||||
async function checkMusicBrainzStatus(): Promise<ApiCheckStatus> {
|
||||
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||
try {
|
||||
const isOnline = await withTimeout(CheckAPIStatus("musicbrainz", "https://musicbrainz.org"), CHECK_TIMEOUT_MS, "API status check timed out after 10 seconds for MusicBrainz");
|
||||
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";
|
||||
}
|
||||
catch {
|
||||
return "offline";
|
||||
}
|
||||
}
|
||||
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
|
||||
return value === "up" ? "online" : "offline";
|
||||
}
|
||||
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
||||
return values.some((value) => value === "up") ? "online" : "offline";
|
||||
}
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
|
||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||
const current = currentStatuses[source.id];
|
||||
acc[source.id] = current === "online" || current === "offline" ? current : "idle";
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
|
||||
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
|
||||
if (!response.ok) {
|
||||
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
|
||||
}
|
||||
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
|
||||
return {
|
||||
tidal: statusFromNextValue(payload.tidal),
|
||||
qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
|
||||
deezer: anyNextVariantUp([payload.deezer_a, payload.deezer_b]),
|
||||
amazon: anyNextVariantUp([payload.amazon_a, payload.amazon_b, payload.amazon_c]),
|
||||
apple: statusFromNextValue(payload.apple),
|
||||
};
|
||||
}
|
||||
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await fetchSpotiFLACNextStatusesOnce();
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
|
||||
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
|
||||
}
|
||||
export function getApiStatusState(): ApiStatusState {
|
||||
return apiStatusState;
|
||||
}
|
||||
@@ -91,70 +130,98 @@ export function subscribeApiStatus(listener: () => void): () => void {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
export function hasApiStatusResults(): boolean {
|
||||
return API_SOURCES.some((source) => {
|
||||
const status = apiStatusState.statuses[source.id];
|
||||
function hasSpotiFLACNextResults(): boolean {
|
||||
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
||||
const status = apiStatusState.nextStatuses[source.id];
|
||||
return status === "online" || status === "offline";
|
||||
});
|
||||
}
|
||||
export function ensureApiStatusCheckStarted(): void {
|
||||
if (!activeCheckAll && !hasApiStatusResults()) {
|
||||
void checkAllApiStatuses(false);
|
||||
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||
if (activeCheckNextOnly) {
|
||||
return activeCheckNextOnly;
|
||||
}
|
||||
}
|
||||
export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise<void> {
|
||||
if (activeCheckAll) {
|
||||
return activeCheckAll;
|
||||
}
|
||||
activeCheckAll = (async () => {
|
||||
const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
||||
activeCheckNextOnly = (async () => {
|
||||
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
isCheckingAll: true,
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
...checkingStatuses,
|
||||
nextStatuses: {
|
||||
...current.nextStatuses,
|
||||
...checkingNextStatuses,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([
|
||||
withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"),
|
||||
checkMusicBrainzStatus(),
|
||||
]);
|
||||
setApiStatusState((current) => {
|
||||
const nextStatuses = { ...current.statuses };
|
||||
if (unifiedResult.status === "fulfilled") {
|
||||
Object.assign(nextStatuses, unifiedResult.value.statuses);
|
||||
}
|
||||
else {
|
||||
nextStatuses.tidal1 = "offline";
|
||||
nextStatuses.tidal2 = "offline";
|
||||
nextStatuses.tidal3 = "offline";
|
||||
nextStatuses.tidal4 = "offline";
|
||||
nextStatuses.tidal5 = "offline";
|
||||
nextStatuses.tidal6 = "offline";
|
||||
nextStatuses.tidal7 = "offline";
|
||||
nextStatuses.qobuz1 = "offline";
|
||||
nextStatuses.qobuz2 = "offline";
|
||||
nextStatuses.qobuz3 = "offline";
|
||||
nextStatuses.amazon1 = "offline";
|
||||
nextStatuses.lrclib = "offline";
|
||||
}
|
||||
nextStatuses.musicbrainz =
|
||||
musicBrainzStatus.status === "fulfilled" ? musicBrainzStatus.value : "offline";
|
||||
return {
|
||||
...current,
|
||||
statuses: nextStatuses,
|
||||
};
|
||||
});
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
nextStatuses: { ...current.nextStatuses },
|
||||
}));
|
||||
const nextStatuses = await checkSpotiFLACNextStatuses();
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
nextStatuses: {
|
||||
...current.nextStatuses,
|
||||
...nextStatuses,
|
||||
},
|
||||
}));
|
||||
}
|
||||
catch {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
|
||||
}));
|
||||
}
|
||||
finally {
|
||||
activeCheckNextOnly = null;
|
||||
}
|
||||
})();
|
||||
return activeCheckNextOnly;
|
||||
}
|
||||
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
||||
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
|
||||
void checkSpotiFLACNextStatusesOnly();
|
||||
}
|
||||
}
|
||||
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,
|
||||
checkingSources: {
|
||||
...current.checkingSources,
|
||||
[sourceId]: true,
|
||||
},
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
[sourceId]: "checking",
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const status = await checkSourceStatus(source);
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
[sourceId]: status,
|
||||
},
|
||||
}));
|
||||
}
|
||||
finally {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
isCheckingAll: false,
|
||||
checkingSources: {
|
||||
...current.checkingSources,
|
||||
[sourceId]: false,
|
||||
},
|
||||
}));
|
||||
activeCheckAll = null;
|
||||
activeSourceChecks.delete(sourceId);
|
||||
}
|
||||
})();
|
||||
return activeCheckAll;
|
||||
activeSourceChecks.set(sourceId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
|
||||
}
|
||||
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
||||
const req = new main.DownloadRequest(request);
|
||||
if (request.tidal_variant !== undefined) {
|
||||
(req as any).tidal_variant = request.tidal_variant;
|
||||
}
|
||||
if (request.use_single_genre !== undefined) {
|
||||
(req as any).use_single_genre = request.use_single_genre;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Settings {
|
||||
embedLyrics: boolean;
|
||||
embedMaxQualityCover: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS";
|
||||
tidalVariant: "tidal" | "alt";
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
amazonQuality: "original";
|
||||
@@ -110,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
embedLyrics: false,
|
||||
embedMaxQualityCover: false,
|
||||
operatingSystem: detectOS(),
|
||||
tidalVariant: "tidal",
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "original",
|
||||
@@ -215,6 +217,9 @@ function getSettingsFromLocalStorage(): Settings {
|
||||
if (!('tidalQuality' in parsed)) {
|
||||
parsed.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!('tidalVariant' in parsed)) {
|
||||
parsed.tidalVariant = "tidal";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
@@ -306,6 +311,9 @@ export async function loadSettings(): Promise<Settings> {
|
||||
if (!('tidalQuality' in parsed)) {
|
||||
parsed.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!('tidalVariant' in parsed)) {
|
||||
parsed.tidalVariant = "tidal";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface DownloadRequest {
|
||||
release_date?: string;
|
||||
cover_url?: string;
|
||||
tidal_api_url?: string;
|
||||
tidal_variant?: "tidal" | "alt";
|
||||
output_dir?: string;
|
||||
audio_format?: string;
|
||||
folder_name?: string;
|
||||
|
||||
Reference in New Issue
Block a user