From 264b474903e10ff446edccad803c5949a86356e2 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Thu, 2 Apr 2026 08:55:24 +0700 Subject: [PATCH] .refine check status --- app.go | 128 ++++++++++++++-------- frontend/src/App.tsx | 2 + frontend/src/components/AboutPage.tsx | 3 +- frontend/src/components/ApiStatusTab.tsx | 49 +-------- frontend/src/hooks/useApiStatus.ts | 25 +++++ frontend/src/hooks/useAvailability.ts | 7 +- frontend/src/lib/api-status.ts | 133 +++++++++++++++++++++++ frontend/src/lib/async-timeout.ts | 23 ++++ 8 files changed, 276 insertions(+), 94 deletions(-) create mode 100644 frontend/src/hooks/useApiStatus.ts create mode 100644 frontend/src/lib/api-status.ts create mode 100644 frontend/src/lib/async-timeout.ts diff --git a/app.go b/app.go index 179548d..f12ca60 100644 --- a/app.go +++ b/app.go @@ -23,10 +23,34 @@ type App struct { ctx context.Context } +const checkOperationTimeout = 10 * time.Second + func NewApp() *App { return &App{} } +type timedResult[T any] struct { + value T + err error +} + +func runWithTimeout[T any](timeout time.Duration, fn func() (T, error)) (T, error) { + resultCh := make(chan timedResult[T], 1) + + go func() { + value, err := fn() + resultCh <- timedResult[T]{value: value, err: err} + }() + + select { + case result := <-resultCh: + return result.value, result.err + case <-time.After(timeout): + var zero T + return zero, fmt.Errorf("operation timed out after %s", timeout) + } +} + func (a *App) getFirstArtist(artistString string) string { if artistString == "" { return "" @@ -757,49 +781,57 @@ func (a *App) ExportFailedDownloads() (string, error) { } func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { - var checkURL string - if apiType == "tidal" { - checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL) - } else if apiType == "qobuz" { - checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&format_id=27", apiURL) - } else if apiType == "qbz" { - checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL) - } else if apiType == "amazon" { - checkURL = fmt.Sprintf("%s/status", apiURL) - } else { - checkURL = apiURL - } + isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) { + var checkURL string + if apiType == "tidal" { + checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL) + } else if apiType == "qobuz" { + checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&format_id=27", apiURL) + } else if apiType == "qbz" { + checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL) + } else if apiType == "amazon" { + checkURL = fmt.Sprintf("%s/status", apiURL) + } else { + checkURL = apiURL + } - client := &http.Client{Timeout: 15 * time.Second} - req, err := http.NewRequest("GET", checkURL, nil) - if err != nil { - return false - } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", checkURL, nil) + if err != nil { + return false, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") - maxRetries := 3 - for i := 0; i < maxRetries; i++ { - resp, err := client.Do(req) - if err == nil { - statusCode := resp.StatusCode - if apiType == "amazon" && statusCode == 200 { - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() - if readErr == nil && strings.Contains(string(body), `"amazonMusic":"up"`) { - return true - } - } else { - resp.Body.Close() - if statusCode == 200 { - return true + maxRetries := 3 + for i := 0; i < maxRetries; i++ { + resp, err := client.Do(req) + if err == nil { + statusCode := resp.StatusCode + if apiType == "amazon" && statusCode == 200 { + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr == nil && strings.Contains(string(body), `"amazonMusic":"up"`) { + return true, nil + } + } else { + resp.Body.Close() + if statusCode == 200 { + return true, nil + } } } + if i < maxRetries-1 { + time.Sleep(1 * time.Second) + } } - if i < maxRetries-1 { - time.Sleep(1 * time.Second) - } + return false, nil + }) + if err != nil { + fmt.Printf("CheckAPIStatus timeout/error for %s (%s): %v\n", apiType, apiURL, err) + return false } - return false + + return isOnline } func (a *App) Quit() { @@ -1085,18 +1117,20 @@ func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) { return "", fmt.Errorf("spotify track ID is required") } - client := backend.NewSongLinkClient() - availability, err := client.CheckTrackAvailability(spotifyTrackID) - if err != nil { - return "", err - } + return runWithTimeout(checkOperationTimeout, func() (string, error) { + client := backend.NewSongLinkClient() + availability, err := client.CheckTrackAvailability(spotifyTrackID) + if err != nil { + return "", err + } - jsonData, err := json.Marshal(availability) - if err != nil { - return "", fmt.Errorf("failed to encode response: %v", err) - } + jsonData, err := json.Marshal(availability) + if err != nil { + return "", fmt.Errorf("failed to encode response: %v", err) + } - return string(jsonData), nil + return string(jsonData), nil + }) } func (a *App) IsFFmpegInstalled() (bool, error) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 104fb33..bff17ac 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 { ensureApiStatusCheckStarted } from "@/lib/api-status"; import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; const HISTORY_KEY = "spotiflac_fetch_history"; @@ -179,6 +180,7 @@ function App() { }; mediaQuery.addEventListener("change", handleChange); checkForUpdates(); + ensureApiStatusCheckStarted(); loadHistory(); const handleScroll = () => { setShowScrollTop(window.scrollY > 300); diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index bb374eb..dc11811 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -273,8 +273,7 @@ export function AboutPage() { Note

- SpotiFLAC Next is a separate project created as a thank-you - to everyone who has supported SpotiFLAC on Ko-fi. + This project is a thank-you to everyone who supported SpotiFLAC on Ko-fi.

)} diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index 707a0d9..902f939 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -1,59 +1,20 @@ -import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react"; -import { CheckAPIStatus } from "../../wailsjs/go/main/App"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; -interface ApiSource { - id: string; - type: string; - name: string; - url: string; -} -const 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" }, -]; +import { useApiStatus } from "@/hooks/useApiStatus"; + export function ApiStatusTab() { - const [statuses, setStatuses] = useState>({}); - const [isCheckingAll, setIsCheckingAll] = useState(false); - const checkStatus = async (sourceId: string, apiType: string, url: string) => { - setStatuses(prev => ({ ...prev, [sourceId]: "checking" })); - try { - const isOnline = await CheckAPIStatus(apiType, url); - setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" })); - } - catch (error) { - setStatuses(prev => ({ ...prev, [sourceId]: "offline" })); - } - }; - const checkAll = async () => { - setIsCheckingAll(true); - const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url)); - await Promise.allSettled(promises); - setIsCheckingAll(false); - }; - useEffect(() => { - checkAll(); - }, []); + const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus(); return (
-
- {SOURCES.map((source) => { + {sources.map((source) => { const status = statuses[source.id] || "idle"; return (
diff --git a/frontend/src/hooks/useApiStatus.ts b/frontend/src/hooks/useApiStatus.ts new file mode 100644 index 0000000..b74373d --- /dev/null +++ b/frontend/src/hooks/useApiStatus.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { + API_SOURCES, + checkAllApiStatuses, + ensureApiStatusCheckStarted, + getApiStatusState, + subscribeApiStatus, +} from "@/lib/api-status"; + +export function useApiStatus() { + const [state, setState] = useState(getApiStatusState); + + useEffect(() => { + ensureApiStatusCheckStarted(); + return subscribeApiStatus(() => { + setState(getApiStatusState()); + }); + }, []); + + return { + ...state, + sources: API_SOURCES, + refreshAll: checkAllApiStatuses, + }; +} diff --git a/frontend/src/hooks/useAvailability.ts b/frontend/src/hooks/useAvailability.ts index efb7dd8..440fbe1 100644 --- a/frontend/src/hooks/useAvailability.ts +++ b/frontend/src/hooks/useAvailability.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from "react"; import { CheckTrackAvailability } from "../../wailsjs/go/main/App"; import type { TrackAvailability } from "@/types/api"; import { logger } from "@/lib/logger"; +import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout"; export function useAvailability() { const [checking, setChecking] = useState(false); const [checkingTrackId, setCheckingTrackId] = useState(null); @@ -20,7 +21,11 @@ export function useAvailability() { setError(null); try { logger.info(`Checking availability for track: ${spotifyId}`); - const response = await CheckTrackAvailability(spotifyId); + const response = await withTimeout( + CheckTrackAvailability(spotifyId), + CHECK_TIMEOUT_MS, + `Availability check timed out after 10 seconds for ${spotifyId}`, + ); const availability: TrackAvailability = JSON.parse(response); setAvailabilityMap((prev) => { const newMap = new Map(prev); diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts new file mode 100644 index 0000000..d958a81 --- /dev/null +++ b/frontend/src/lib/api-status.ts @@ -0,0 +1,133 @@ +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 { + id: string; + type: string; + 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" }, +]; + +type ApiStatusState = { + isCheckingAll: boolean; + statuses: Record; +}; + +let apiStatusState: ApiStatusState = { + isCheckingAll: false, + statuses: {}, +}; + +let activeCheckAll: Promise | null = null; + +const listeners = new Set<() => void>(); + +function emitApiStatusChange() { + for (const listener of listeners) { + listener(); + } +} + +function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) { + apiStatusState = updater(apiStatusState); + emitApiStatusChange(); +} + +async function checkSingleApiStatus(source: ApiSource): Promise { + setApiStatusState((current) => ({ + ...current, + statuses: { + ...current.statuses, + [source.id]: "checking", + }, + })); + + try { + const isOnline = await withTimeout( + CheckAPIStatus(source.type, source.url), + CHECK_TIMEOUT_MS, + `API status check timed out after 10 seconds for ${source.url}`, + ); + + setApiStatusState((current) => ({ + ...current, + statuses: { + ...current.statuses, + [source.id]: isOnline ? "online" : "offline", + }, + })); + } catch { + setApiStatusState((current) => ({ + ...current, + statuses: { + ...current.statuses, + [source.id]: "offline", + }, + })); + } +} + +export function getApiStatusState(): ApiStatusState { + return apiStatusState; +} + +export function subscribeApiStatus(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function hasApiStatusResults(): boolean { + return API_SOURCES.some((source) => { + const status = apiStatusState.statuses[source.id]; + return status === "online" || status === "offline"; + }); +} + +export function ensureApiStatusCheckStarted(): void { + if (!activeCheckAll && !hasApiStatusResults()) { + void checkAllApiStatuses(); + } +} + +export async function checkAllApiStatuses(): Promise { + if (activeCheckAll) { + return activeCheckAll; + } + + activeCheckAll = (async () => { + setApiStatusState((current) => ({ + ...current, + isCheckingAll: true, + })); + + try { + await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source))); + } finally { + setApiStatusState((current) => ({ + ...current, + isCheckingAll: false, + })); + activeCheckAll = null; + } + })(); + + return activeCheckAll; +} diff --git a/frontend/src/lib/async-timeout.ts b/frontend/src/lib/async-timeout.ts new file mode 100644 index 0000000..217deb7 --- /dev/null +++ b/frontend/src/lib/async-timeout.ts @@ -0,0 +1,23 @@ +export const CHECK_TIMEOUT_MS = 10 * 1000; + +export function withTimeout( + promise: Promise, + timeoutMs: number = CHECK_TIMEOUT_MS, + message: string = `Operation timed out after ${Math.round(timeoutMs / 1000)} seconds`, +): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + reject(new Error(message)); + }, timeoutMs); + + promise + .then((value) => { + window.clearTimeout(timer); + resolve(value); + }) + .catch((error) => { + window.clearTimeout(timer); + reject(error); + }); + }); +}