.unified status check

This commit is contained in:
afkarxyz
2026-04-14 07:14:18 +07:00
parent c0c1348c3f
commit f75081780e
5 changed files with 147 additions and 26 deletions
+67
View File
@@ -14,6 +14,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/afkarxyz/SpotiFLAC/backend" "github.com/afkarxyz/SpotiFLAC/backend"
@@ -33,6 +34,14 @@ type CurrentIPInfo struct {
} }
const checkOperationTimeout = 10 * time.Second 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 { func NewApp() *App {
return &App{} return &App{}
@@ -143,6 +152,60 @@ func previewResponseBody(body []byte, maxLen int) string {
return preview 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) { func fetchCurrentIPInfo() (CurrentIPInfo, error) {
type ipwhoisResponse struct { type ipwhoisResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -250,6 +313,10 @@ func (a *App) GetCurrentIPInfo() (string, error) {
return string(payload), nil return string(payload), nil
} }
func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) {
return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL)
}
func (a *App) getFirstArtist(artistString string) string { func (a *App) getFirstArtist(artistString string) string {
if artistString == "" { if artistString == "" {
return "" return ""
+1 -1
View File
@@ -11,6 +11,6 @@ export function useApiStatus() {
return { return {
...state, ...state,
sources: API_SOURCES, sources: API_SOURCES,
refreshAll: checkAllApiStatuses, refreshAll: () => checkAllApiStatuses(true),
}; };
} }
+73 -25
View File
@@ -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"; import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource { export interface ApiSource {
@@ -32,6 +32,15 @@ let apiStatusState: ApiStatusState = {
}; };
let activeCheckAll: Promise<void> | null = null; let activeCheckAll: Promise<void> | null = null;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
type SpotiFLACUnifiedStatusResponse = {
tidal?: string;
qobuz_a?: string;
qobuz_b?: string;
qobuz_c?: string;
amazon?: string;
lrclib?: string;
};
function emitApiStatusChange() { function emitApiStatusChange() {
for (const listener of listeners) { for (const listener of listeners) {
listener(); listener();
@@ -41,32 +50,37 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState)
apiStatusState = updater(apiStatusState); apiStatusState = updater(apiStatusState);
emitApiStatusChange(); emitApiStatusChange();
} }
async function checkSingleApiStatus(source: ApiSource): Promise<void> { function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus {
setApiStatusState((current) => ({ return value === "up" ? "online" : "offline";
...current, }
async function fetchUnifiedStatuses(forceRefresh: boolean): Promise<Pick<ApiStatusState, "statuses">> {
const response = await FetchUnifiedAPIStatus(forceRefresh);
const payload = JSON.parse(response) as SpotiFLACUnifiedStatusResponse;
const tidalStatus = statusFromUnifiedValue(payload.tidal);
return {
statuses: { statuses: {
...current.statuses, tidal1: tidalStatus,
[source.id]: "checking", 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<ApiCheckStatus> {
try { try {
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.url}`); const isOnline = await withTimeout(CheckAPIStatus("musicbrainz", "https://musicbrainz.org"), CHECK_TIMEOUT_MS, "API status check timed out after 10 seconds for MusicBrainz");
setApiStatusState((current) => ({ return isOnline ? "online" : "offline";
...current,
statuses: {
...current.statuses,
[source.id]: isOnline ? "online" : "offline",
},
}));
} }
catch { catch {
setApiStatusState((current) => ({ return "offline";
...current,
statuses: {
...current.statuses,
[source.id]: "offline",
},
}));
} }
} }
export function getApiStatusState(): ApiStatusState { export function getApiStatusState(): ApiStatusState {
@@ -86,20 +100,54 @@ export function hasApiStatusResults(): boolean {
} }
export function ensureApiStatusCheckStarted(): void { export function ensureApiStatusCheckStarted(): void {
if (!activeCheckAll && !hasApiStatusResults()) { if (!activeCheckAll && !hasApiStatusResults()) {
void checkAllApiStatuses(); void checkAllApiStatuses(false);
} }
} }
export async function checkAllApiStatuses(): Promise<void> { export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise<void> {
if (activeCheckAll) { if (activeCheckAll) {
return activeCheckAll; return activeCheckAll;
} }
activeCheckAll = (async () => { activeCheckAll = (async () => {
const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
setApiStatusState((current) => ({ setApiStatusState((current) => ({
...current, ...current,
isCheckingAll: true, isCheckingAll: true,
statuses: {
...current.statuses,
...checkingStatuses,
},
})); }));
try { 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 { finally {
setApiStatusState((current) => ({ setApiStatusState((current) => ({
+2
View File
@@ -53,6 +53,8 @@ export function DownloadTrack(arg1:main.DownloadRequest):Promise<main.DownloadRe
export function ExportFailedDownloads():Promise<string>; export function ExportFailedDownloads():Promise<string>;
export function FetchUnifiedAPIStatus(arg1:boolean):Promise<string>;
export function GetBrewPath():Promise<string>; export function GetBrewPath():Promise<string>;
export function GetConfigPath():Promise<string>; export function GetConfigPath():Promise<string>;
+4
View File
@@ -102,6 +102,10 @@ export function ExportFailedDownloads() {
return window['go']['main']['App']['ExportFailedDownloads'](); return window['go']['main']['App']['ExportFailedDownloads']();
} }
export function FetchUnifiedAPIStatus(arg1) {
return window['go']['main']['App']['FetchUnifiedAPIStatus'](arg1);
}
export function GetBrewPath() { export function GetBrewPath() {
return window['go']['main']['App']['GetBrewPath'](); return window['go']['main']['App']['GetBrewPath']();
} }