.refine status check

This commit is contained in:
afkarxyz
2026-04-19 22:35:03 +07:00
parent 3af9327a3d
commit a24ca370eb
7 changed files with 262 additions and 43 deletions
+2
View File
@@ -32,6 +32,7 @@ import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { buildPlaylistFolderName } from "@/lib/playlist";
@@ -197,6 +198,7 @@ function App() {
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
ensureSpotiFLACNextStatusCheckStarted();
void loadHistory();
return () => {
mediaQuery.removeEventListener("change", handleChange);
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+66 -18
View File
@@ -1,34 +1,82 @@
import { Button } from "@/components/ui/button";
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon } from "./PlatformIcons";
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, checkAll } = 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 checkAll()} disabled={isCheckingAll} className="gap-2">
{isCheckingAll ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
Check
</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 === "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}/>;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { API_SOURCES, checkAllApiStatuses, 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(() => {
@@ -10,6 +10,6 @@ export function useApiStatus() {
return {
...state,
sources: API_SOURCES,
checkAll: () => checkAllApiStatuses(false),
checkOne: (sourceId: string) => checkApiStatus(sourceId),
};
}
+184 -23
View File
@@ -10,6 +10,24 @@ export interface ApiSource {
url: string;
}
interface SpotiFLACNextSource {
id: string;
name: string;
}
type SpotiFLACNextStatusResponse = {
tidal?: string;
qobuz_a?: string;
qobuz_b?: string;
qobuz_c?: 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: "" },
@@ -17,17 +35,32 @@ export const API_SOURCES: ApiSource[] = [
{ 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 = {
isCheckingAll: boolean;
checkingSources: Record<string, boolean>;
statuses: Record<string, ApiCheckStatus>;
nextStatuses: Record<string, ApiCheckStatus>;
};
let apiStatusState: ApiStatusState = {
isCheckingAll: false,
checkingSources: {},
statuses: {},
nextStatuses: {},
};
let activeCheckAll: Promise<void> | null = null;
let activeCheckNextOnly: Promise<void> | null = null;
const activeSourceChecks = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
function emitApiStatusChange() {
@@ -51,6 +84,67 @@ async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
}
}
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;
}
@@ -62,44 +156,111 @@ export function subscribeApiStatus(listener: () => void): () => void {
};
}
export async function checkAllApiStatuses(_forceRefresh: boolean = false): Promise<void> {
if (activeCheckAll) {
return activeCheckAll;
function hasSpotiFLACNextResults(): boolean {
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
const status = apiStatusState.nextStatuses[source.id];
return status === "online" || status === "offline";
});
}
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
if (activeCheckNextOnly) {
return activeCheckNextOnly;
}
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 results = await Promise.all(API_SOURCES.map(async (source) => ({
id: source.id,
status: await checkSourceStatus(source),
})));
setApiStatusState((current) => ({
...current,
nextStatuses: { ...current.nextStatuses },
}));
const nextStatuses = await checkSpotiFLACNextStatuses();
setApiStatusState((current) => ({
...current,
statuses: results.reduce<Record<string, ApiCheckStatus>>((acc, result) => {
acc[result.id] = result.status;
return acc;
}, { ...current.statuses }),
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;
}