.unified status check
This commit is contained in:
@@ -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 ""
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ export function useApiStatus() {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
sources: API_SOURCES,
|
sources: API_SOURCES,
|
||||||
refreshAll: checkAllApiStatuses,
|
refreshAll: () => checkAllApiStatuses(true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
Vendored
+2
@@ -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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user