diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df1ca54..e5288d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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); diff --git a/frontend/src/assets/icons/am.png b/frontend/src/assets/icons/am.png new file mode 100644 index 0000000..5040e0e Binary files /dev/null and b/frontend/src/assets/icons/am.png differ diff --git a/frontend/src/assets/icons/dzr.png b/frontend/src/assets/icons/dzr.png new file mode 100644 index 0000000..c31e8be Binary files /dev/null and b/frontend/src/assets/icons/dzr.png differ diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index 0617a15..730a55e 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -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 ; + } + if (status === "offline") { + return ; + } + return null; +} + +function renderPlatformIcon(type: string) { + if (type === "tidal") { + return ; + } + if (type === "amazon") { + return ; + } + if (type === "musicbrainz") { + return ; + } + if (type === "deezer") { + return ; + } + if (type === "apple") { + return ; + } + return ; +} + export function ApiStatusTab() { - const { sources, statuses, isCheckingAll, checkAll } = useApiStatus(); + const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus(); return (
-
- +
+

SpotiFLAC Services

+ +
+ {sources.map((source) => { + const status = statuses[source.id] || "idle"; + const isChecking = checkingSources[source.id] === true; + return (
+
+
+ {renderPlatformIcon(source.type)} +

{source.name}

+
+
{renderStatusIcon(status)}
+
+ +
); + })} +
-
- {sources.map((source) => { - const status = statuses[source.id] || "idle"; +
+ +
+

SpotiFLAC Next Services

+ +
+ {SPOTIFLAC_NEXT_SOURCES.map((source) => { + const status = nextStatuses[source.id] || "idle"; return (
- {source.type === "tidal" ? : source.type === "amazon" ? : source.type === "musicbrainz" ? : } + {renderPlatformIcon(source.id)}

{source.name}

- -
- {status === "checking" && } - {status === "online" && } - {status === "offline" && } - {status === "idle" &&
} -
+
{renderStatusIcon(status)}
); })} +
); } diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx index e944837..388e1c6 100644 --- a/frontend/src/components/PlatformIcons.tsx +++ b/frontend/src/components/PlatformIcons.tsx @@ -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 ; } +export function AppleMusicIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function DeezerIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} export function LrclibIcon({ className = "w-4 h-4" }: PlatformIconProps) { return ; } diff --git a/frontend/src/hooks/useApiStatus.ts b/frontend/src/hooks/useApiStatus.ts index bff3601..b311a45 100644 --- a/frontend/src/hooks/useApiStatus.ts +++ b/frontend/src/hooks/useApiStatus.ts @@ -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), }; } diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts index 15dd0b7..c89b4a4 100644 --- a/frontend/src/lib/api-status.ts +++ b/frontend/src/lib/api-status.ts @@ -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; statuses: Record; + nextStatuses: Record; }; let apiStatusState: ApiStatusState = { - isCheckingAll: false, + checkingSources: {}, statuses: {}, + nextStatuses: {}, }; -let activeCheckAll: Promise | null = null; +let activeCheckNextOnly: Promise | null = null; +const activeSourceChecks = new Map>(); const listeners = new Set<() => void>(); function emitApiStatusChange() { @@ -51,6 +84,67 @@ async function checkSourceStatus(source: ApiSource): Promise { } } +function statusFromNextValue(value: string | undefined): ApiCheckStatus { + return value === "up" ? "online" : "offline"; +} + +function anyNextVariantUp(values: Array): ApiCheckStatus { + return values.some((value) => value === "up") ? "online" : "offline"; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function getSafeNextStatusesFallback(currentStatuses: Record): Record { + return SPOTIFLAC_NEXT_SOURCES.reduce>((acc, source) => { + const current = currentStatuses[source.id]; + acc[source.id] = current === "online" || current === "offline" ? current : "idle"; + return acc; + }, {}); +} + +async function fetchSpotiFLACNextStatusesOnce(): Promise> { + 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> { + 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 { - 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 { + 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>((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 { + 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; }