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;
}