.refine status check
This commit is contained in:
@@ -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 |
@@ -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";
|
||||
export function ApiStatusTab() {
|
||||
const { sources, statuses, isCheckingAll, checkAll } = 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>
|
||||
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
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, nextStatuses, checkingSources, checkOne } = useApiStatus();
|
||||
return (<div className="space-y-6">
|
||||
<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";
|
||||
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
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">
|
||||
{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.type)}
|
||||
<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 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="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">
|
||||
{renderPlatformIcon(source.id)}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</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}/>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user