diff --git a/app.go b/app.go index 8baa7d7..998b7c5 100644 --- a/app.go +++ b/app.go @@ -14,6 +14,7 @@ import ( "net/http" "strings" + "sync" "time" "github.com/afkarxyz/SpotiFLAC/backend" @@ -33,6 +34,14 @@ type CurrentIPInfo struct { } const checkOperationTimeout = 10 * time.Second +const unifiedStatusAPIURL = "https://api-status.afkarxyz.qzz.io/api/status/spotiflac/" +const unifiedStatusCacheTTL = 5 * time.Second + +var ( + unifiedStatusCacheMu sync.Mutex + unifiedStatusCacheBody string + unifiedStatusCacheExpiry time.Time +) func NewApp() *App { return &App{} @@ -143,6 +152,60 @@ func previewResponseBody(body []byte, maxLen int) string { return preview } +func fetchUnifiedStatusPayload(forceRefresh bool, endpoint string) (string, error) { + unifiedStatusCacheMu.Lock() + defer unifiedStatusCacheMu.Unlock() + + if !forceRefresh && unifiedStatusCacheBody != "" && time.Now().Before(unifiedStatusCacheExpiry) { + return unifiedStatusCacheBody, nil + } + + client := &http.Client{Timeout: 10 * time.Second} + maxRetries := 3 + var lastErr error + + for i := 0; i < maxRetries; i++ { + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("failed to create unified status request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err == nil { + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + lastErr = fmt.Errorf("attempt %d: failed reading response: %w", i+1, readErr) + } else if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("attempt %d: returned status %d (%s)", i+1, resp.StatusCode, previewResponseBody(body, 200)) + } else { + payload := strings.TrimSpace(string(body)) + if payload == "" { + lastErr = fmt.Errorf("attempt %d: empty response body", i+1) + } else { + unifiedStatusCacheBody = payload + unifiedStatusCacheExpiry = time.Now().Add(unifiedStatusCacheTTL) + return payload, nil + } + } + } else { + lastErr = fmt.Errorf("attempt %d: connection failed: %w", i+1, err) + } + + if i < maxRetries-1 { + time.Sleep(1 * time.Second) + } + } + + if lastErr == nil { + lastErr = fmt.Errorf("unknown error") + } + + return "", fmt.Errorf("unified status API failed after %d retries: %w", maxRetries, lastErr) +} + func fetchCurrentIPInfo() (CurrentIPInfo, error) { type ipwhoisResponse struct { Success bool `json:"success"` @@ -250,6 +313,10 @@ func (a *App) GetCurrentIPInfo() (string, error) { return string(payload), nil } +func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) { + return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL) +} + func (a *App) getFirstArtist(artistString string) string { if artistString == "" { return "" diff --git a/frontend/src/hooks/useApiStatus.ts b/frontend/src/hooks/useApiStatus.ts index 589a730..24a8b8b 100644 --- a/frontend/src/hooks/useApiStatus.ts +++ b/frontend/src/hooks/useApiStatus.ts @@ -11,6 +11,6 @@ export function useApiStatus() { return { ...state, sources: API_SOURCES, - refreshAll: checkAllApiStatuses, + refreshAll: () => checkAllApiStatuses(true), }; } diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts index ba80ccb..4e741d9 100644 --- a/frontend/src/lib/api-status.ts +++ b/frontend/src/lib/api-status.ts @@ -1,4 +1,4 @@ -import { CheckAPIStatus } from "../../wailsjs/go/main/App"; +import { CheckAPIStatus, FetchUnifiedAPIStatus } from "../../wailsjs/go/main/App"; import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout"; export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; export interface ApiSource { @@ -32,6 +32,15 @@ let apiStatusState: ApiStatusState = { }; let activeCheckAll: Promise | null = null; const listeners = new Set<() => void>(); + +type SpotiFLACUnifiedStatusResponse = { + tidal?: string; + qobuz_a?: string; + qobuz_b?: string; + qobuz_c?: string; + amazon?: string; + lrclib?: string; +}; function emitApiStatusChange() { for (const listener of listeners) { listener(); @@ -41,32 +50,37 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) apiStatusState = updater(apiStatusState); emitApiStatusChange(); } -async function checkSingleApiStatus(source: ApiSource): Promise { - setApiStatusState((current) => ({ - ...current, +function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus { + return value === "up" ? "online" : "offline"; +} +async function fetchUnifiedStatuses(forceRefresh: boolean): Promise> { + const response = await FetchUnifiedAPIStatus(forceRefresh); + const payload = JSON.parse(response) as SpotiFLACUnifiedStatusResponse; + const tidalStatus = statusFromUnifiedValue(payload.tidal); + return { statuses: { - ...current.statuses, - [source.id]: "checking", + tidal1: tidalStatus, + tidal2: tidalStatus, + tidal3: tidalStatus, + tidal4: tidalStatus, + tidal5: tidalStatus, + tidal6: tidalStatus, + tidal7: tidalStatus, + qobuz1: statusFromUnifiedValue(payload.qobuz_a), + qobuz2: statusFromUnifiedValue(payload.qobuz_b), + qobuz3: statusFromUnifiedValue(payload.qobuz_c), + amazon1: statusFromUnifiedValue(payload.amazon), + lrclib: statusFromUnifiedValue(payload.lrclib), }, - })); + }; +} +async function checkMusicBrainzStatus(): Promise { 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", - }, - })); + const isOnline = await withTimeout(CheckAPIStatus("musicbrainz", "https://musicbrainz.org"), CHECK_TIMEOUT_MS, "API status check timed out after 10 seconds for MusicBrainz"); + return isOnline ? "online" : "offline"; } catch { - setApiStatusState((current) => ({ - ...current, - statuses: { - ...current.statuses, - [source.id]: "offline", - }, - })); + return "offline"; } } export function getApiStatusState(): ApiStatusState { @@ -86,20 +100,54 @@ export function hasApiStatusResults(): boolean { } export function ensureApiStatusCheckStarted(): void { if (!activeCheckAll && !hasApiStatusResults()) { - void checkAllApiStatuses(); + void checkAllApiStatuses(false); } } -export async function checkAllApiStatuses(): Promise { +export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise { if (activeCheckAll) { return activeCheckAll; } activeCheckAll = (async () => { + const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus])); setApiStatusState((current) => ({ ...current, isCheckingAll: true, + statuses: { + ...current.statuses, + ...checkingStatuses, + }, })); try { - await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source))); + const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([ + withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"), + checkMusicBrainzStatus(), + ]); + setApiStatusState((current) => { + const nextStatuses = { ...current.statuses }; + if (unifiedResult.status === "fulfilled") { + Object.assign(nextStatuses, unifiedResult.value.statuses); + } + else { + nextStatuses.tidal1 = "offline"; + nextStatuses.tidal2 = "offline"; + nextStatuses.tidal3 = "offline"; + nextStatuses.tidal4 = "offline"; + nextStatuses.tidal5 = "offline"; + nextStatuses.tidal6 = "offline"; + nextStatuses.tidal7 = "offline"; + nextStatuses.qobuz1 = "offline"; + nextStatuses.qobuz2 = "offline"; + nextStatuses.qobuz3 = "offline"; + nextStatuses.amazon1 = "offline"; + nextStatuses.lrclib = "offline"; + } + nextStatuses.musicbrainz = + musicBrainzStatus.status === "fulfilled" ? musicBrainzStatus.value : "offline"; + return { + ...current, + statuses: nextStatuses, + }; + }); } finally { setApiStatusState((current) => ({ diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index d3caaa0..76e38c2 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -53,6 +53,8 @@ export function DownloadTrack(arg1:main.DownloadRequest):Promise; +export function FetchUnifiedAPIStatus(arg1:boolean):Promise; + export function GetBrewPath():Promise; export function GetConfigPath():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 5589d46..0bf63fa 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -102,6 +102,10 @@ export function ExportFailedDownloads() { return window['go']['main']['App']['ExportFailedDownloads'](); } +export function FetchUnifiedAPIStatus(arg1) { + return window['go']['main']['App']['FetchUnifiedAPIStatus'](arg1); +} + export function GetBrewPath() { return window['go']['main']['App']['GetBrewPath'](); }