diff --git a/specs/components/input/types.d.ts b/specs/components/input/types.d.ts index b0c9468d..780dfb30 100644 --- a/specs/components/input/types.d.ts +++ b/specs/components/input/types.d.ts @@ -11,11 +11,26 @@ import type { DiffuseElement } from "~/common/element.js"; */ export type Consult = | { supported: false; reason: string } - | { supported: true; consult: "undetermined" | boolean }; + | { supported: true; consult: "undetermined" | ConsultResult }; -export type ConsultGrouping = - | { available: false; reason: string; scheme: string; uris: string[] } - | { available: true; scheme: string; uris: string[] }; +/** + * Tri-state availability for a single source. + * + * - `"yes"` → the source confirmed it is reachable; + * - `"no"` → the source explicitly rejected (e.g. HTTP 404, auth fail); + * - `"unsure"` → the consult was inconclusive (network blip, timeout, + * aborted fetch). Consult results are never cached in + * this state, and consumers should treat the source + * optimistically rather than hiding it. + */ +export type ConsultResult = "yes" | "no" | "unsure"; + +export type ConsultGrouping = { + available: ConsultResult; + reason?: string; + scheme: string; + uris: string[]; +}; export type GroupConsult = Record; diff --git a/src/components/configurator/input/worker.js b/src/components/configurator/input/worker.js index 22e62328..7ea9e0dc 100644 --- a/src/components/configurator/input/worker.js +++ b/src/components/configurator/input/worker.js @@ -96,7 +96,7 @@ export async function groupConsult({ data, ports }) { if (!input) { return { [scheme]: { - available: false, + available: "no", reason: "Unsupported scheme", scheme, uris: groups[scheme] ?? [], diff --git a/src/components/input/common.js b/src/components/input/common.js index 57d0a9b2..9bd20bde 100644 --- a/src/components/input/common.js +++ b/src/components/input/common.js @@ -1,4 +1,5 @@ /** + * @import {ConsultResult} from "@specs/components/input/types.d.ts" * @import {Track} from "~/definitions/types.d.ts" */ @@ -6,28 +7,65 @@ * Creates a time-cached version of an async consult function. * Results are cached per key for the given TTL. * + * The underlying `fn` returns a {@link ConsultResult}: + * - `"yes"` → server confirmed reachable (cached for the full TTL); + * - `"no"` → server explicitly rejected, or a transient consult + * failure occurred (cached for the full TTL only when the + * underlying fn actually returned `"no"`; an `"unsure"` + * result is **not** cached — the next consult retries + * immediately); + * - `"unsure"` → inconclusive (network blip, timeout, aborted fetch). + * + * The wrapper normalises `"unsure"` to `"no"` for the caller: we can't + * confirm availability, so callers should hide the source's tracks + * until a real consult succeeds. The key difference vs. a genuine + * `"no"` is purely about caching: `"unsure"` is never written to the + * cache, so the next consult call will retry the underlying fn rather + * than wait out the TTL. + * * @template T - * @param {(arg: T) => Promise} fn + * @param {(arg: T) => Promise} fn * @param {(arg: T) => string} keyFn * @param {number} ttl - Cache TTL in milliseconds - * @returns {(arg: T) => Promise} + * @returns {(arg: T) => Promise} * * @example Caches results and avoids calling fn more than once per key * ```js * import { cachedConsult } from "~/components/input/common.js"; * * let callCount = 0; - * const cached = cachedConsult(async (uri) => { callCount++; return true; }, (uri) => uri); + * const cached = cachedConsult(async () => { callCount++; return "yes"; }, (k) => k); * - * const r1 = await cached("https://example.com/stream"); - * const r2 = await cached("https://example.com/stream"); + * const r1 = await cached("k"); + * const r2 = await cached("k"); * - * if (r1 !== true || r2 !== true) throw new Error("should return cached value"); + * if (r1 !== "yes" || r2 !== "yes") throw new Error("should return cached value"); * if (callCount !== 1) throw new Error("fn should only be called once per key"); * ``` + * + * @example An `"unsure"` result is normalised to `"no"` and not cached + * ```js + * import { cachedConsult } from "~/components/input/common.js"; + * + * let n = 0; + * const cached = cachedConsult( + * async () => (n++ === 0 ? "unsure" : "yes"), + * (k) => k, + * ); + * + * // First call: fn returns "unsure". The wrapper returns "no" without + * // caching, so the caller hides the source's tracks until a real + * // consult succeeds. + * const r1 = await cached("k"); + * if (r1 !== "no") throw new Error("first call should normalise \"unsure\" to \"no\""); + * + * // Second call: cache is empty so fn runs again and returns "yes". + * const r2 = await cached("k"); + * if (r2 !== "yes") throw new Error("second call should return the fresh \"yes\""); + * ``` */ export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) { - /** @type {Map} */ + /** @type {Map} */ const cache = new Map(); return async (arg) => { @@ -40,6 +78,18 @@ export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) { } const value = await fn(arg); + + // `"unsure"` means we couldn't confirm availability — surface it + // as `"no"` to callers (their tracks shouldn't show until a real + // consult succeeds), but never cache it so the next consult call + // retries the underlying fn immediately rather than waiting out + // the TTL. This is what distinguishes a transient network blip + // from a server-confirmed `"no"`: both look like "unavailable" to + // callers, but only the latter sticks for the cache window. + if (value === "unsure") { + return "no"; + } + cache.set(key, { value, expiry: now + ttl }); return value; }; diff --git a/src/components/input/dropbox/common.js b/src/components/input/dropbox/common.js index 4e84b60b..6fedd764 100644 --- a/src/components/input/dropbox/common.js +++ b/src/components/input/dropbox/common.js @@ -210,17 +210,23 @@ export async function getTemporaryLink(accessToken, filePath) { /** * @param {string} accessToken - * @returns {Promise} + * @returns {Promise} */ export async function checkAccess(accessToken) { - const resp = await fetch( - "https://api.dropboxapi.com/2/users/get_current_account", - { - method: "POST", - headers: { "Authorization": `Bearer ${accessToken}` }, - }, - ); - return resp.ok; + try { + const resp = await fetch( + "https://api.dropboxapi.com/2/users/get_current_account", + { + method: "POST", + headers: { "Authorization": `Bearer ${accessToken}` }, + }, + ); + return resp.ok ? "yes" : "no"; + } catch { + // Network error: inconclusive — let `cachedConsult` keep the last + // known availability rather than caching a sticky "no". + return "unsure"; + } } export const checkAccessCached = cachedConsult(checkAccess, (token) => token); diff --git a/src/components/input/dropbox/worker.js b/src/components/input/dropbox/worker.js index 6d5dbb7a..067c5ae0 100644 --- a/src/components/input/dropbox/worker.js +++ b/src/components/input/dropbox/worker.js @@ -79,7 +79,7 @@ export async function groupConsult(uris) { const available = await checkAccessCached(account.accessToken); /** @type {ConsultGrouping} */ - const grouping = available + const grouping = available === "yes" ? { available, scheme: SCHEME, uris } : { available, reason: "Dropbox access denied", scheme: SCHEME, uris }; diff --git a/src/components/input/ephemeral-cache/worker.js b/src/components/input/ephemeral-cache/worker.js index 5431c731..4bac0969 100644 --- a/src/components/input/ephemeral-cache/worker.js +++ b/src/components/input/ephemeral-cache/worker.js @@ -35,7 +35,7 @@ export async function consult(uriOrScheme) { } const cached = await IDB.get(CACHE_KEY_PREFIX + uriOrScheme); - return { supported: true, consult: cached !== undefined }; + return { supported: true, consult: cached !== undefined ? "yes" : "no" }; } /** @@ -65,7 +65,7 @@ export async function groupConsult(uris) { return { [SCHEME]: { - available: true, + available: "yes", scheme: SCHEME, uris: uris.filter((_, i) => cached[i] !== undefined), }, diff --git a/src/components/input/https-json/common.js b/src/components/input/https-json/common.js index e39f3566..13af8507 100644 --- a/src/components/input/https-json/common.js +++ b/src/components/input/https-json/common.js @@ -157,23 +157,28 @@ export function groupUrisByServer(uris) { /** * @param {Server} server - * @returns {Promise} + * @returns {Promise} */ async function checkAccess(server) { - try { - const url = toHttpUrl(server, server.dir); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + const url = toHttpUrl(server, server.dir); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + try { const response = await fetch(url, { headers: { "Accept": "application/json" }, signal: controller.signal, }); clearTimeout(timeoutId); - return response.ok; + return response.ok ? "yes" : "no"; } catch { - return false; + // Network error or timeout: inconclusive — let `cachedConsult` fall + // back to the last known availability instead of caching a sticky + // "no" for the full TTL (which would hide every track from this + // server for 5 minutes after a laptop wake). + clearTimeout(timeoutId); + return "unsure"; } } @@ -183,64 +188,91 @@ export const checkAccessCached = cachedConsult(checkAccess, serverId); * List all files on the server under server.dir using JSON directory listing. * Fetches each directory with `Accept: application/json` and recurses into subdirs. * + * Returns `null` if the root directory listing could not be fetched at all + * (network error, non-2xx, or non-JSON response) — this signals a transient + * outage rather than "directory is genuinely empty" so the caller can + * preserve previously-cached tracks instead of wiping them with a placeholder. + * * @param {Server} server - * @returns {Promise} + * @returns {Promise} */ export async function listFiles(server) { const paths = /** @type {string[]} */ ([]); const exclude = new Set(server.exclude ?? []); - await listDir(server, server.dir, paths, exclude); + + let rootEntries; + try { + rootEntries = await fetchDirEntries(server, server.dir); + } catch { + // Root fetch failed — server unreachable or not a JSON listing. + // The caller can use this to preserve cached tracks rather than + // replacing them with a placeholder. + return null; + } + + await walkEntries(server, server.dir, rootEntries, paths, exclude); return paths; } /** * @param {Server} server * @param {string} dir - * @param {string[]} paths - * @param {Set} exclude + * @returns {Promise} */ -async function listDir(server, dir, paths, exclude) { +async function fetchDirEntries(server, dir) { const url = toHttpUrl(server, dir); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); let response; try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - response = await fetch(url, { headers: { "Accept": "application/json" }, signal: controller.signal, }); - + } finally { clearTimeout(timeoutId); - } catch { - return; } - if (!response.ok) return; + if (!response.ok) throw new Error(`dir fetch failed: ${response.status}`); /** @type {unknown} */ - let data; - try { - data = await response.json(); - } catch { - return; - } - - if (!Array.isArray(data)) return; + const data = await response.json(); + if (!Array.isArray(data)) throw new Error("dir listing is not an array"); + return /** @type {unknown[]} */ (data); +} +/** + * @param {Server} server + * @param {string} dir + * @param {unknown[]} entries + * @param {string[]} paths + * @param {Set} exclude + */ +async function walkEntries(server, dir, entries, paths, exclude) { const basePath = dir.endsWith("/") ? dir : dir + "/"; - for (const entry of data) { - if (!entry || typeof entry.name !== "string" || !entry.type) continue; + for (const entry of entries) { + if (!entry || typeof /** @type {any} */ (entry).name !== "string" || + !/** @type {any} */ (entry).type) { + continue; + } // Encode each path segment so URIs stay valid for non-ASCII filenames. - const encodedName = encodeURIComponent(entry.name); + const encodedName = encodeURIComponent(/** @type {any} */ (entry).name); const entryPath = basePath + encodedName; - if (entry.type === "directory") { - if (!exclude.has(entry.name)) await listDir(server, entryPath, paths, exclude); - } else if (entry.type === "file") { + if (/** @type {any} */ (entry).type === "directory") { + if (exclude.has(/** @type {any} */ (entry).name)) continue; + // A failing subdir shouldn't fail the whole walk — skip it and + // keep going, matching the previous tolerant behaviour. + try { + const subEntries = await fetchDirEntries(server, entryPath); + await walkEntries(server, entryPath, subEntries, paths, exclude); + } catch { + // Subdir unavailable: skip. + } + } else if (/** @type {any} */ (entry).type === "file") { paths.push(entryPath); } } diff --git a/src/components/input/https-json/worker.js b/src/components/input/https-json/worker.js index 0e29aa64..29f5bcc3 100644 --- a/src/components/input/https-json/worker.js +++ b/src/components/input/https-json/worker.js @@ -83,7 +83,7 @@ export async function groupConsult(uris) { const available = await checkAccessCached(server); /** @type {ConsultGrouping} */ - const grouping = available + const grouping = available === "yes" ? { available, scheme: SCHEME, uris } : { available, reason: "Server unreachable", scheme: SCHEME, uris }; @@ -114,48 +114,70 @@ export async function list(cachedTracks = []) { }); }); - const promises = Object.entries(groups).map(async ([id, { server }]) => { - const files = await listFiles(server); - - let tracks = files - .filter((path) => isAudioFile(path)) - .map((path) => { - const cachedTrack = cache[id]?.[safeDecodeURIComponent(path)]; + const promises = Object.entries(groups).map( + async ([id, { server, tracks: cachedServerTracks }]) => { + const files = await listFiles(server); + + // `listFiles` returns `null` when the root directory listing could + // not be fetched at all (e.g. the server was briefly unreachable + // right after a laptop wake). In that case, preserve the previously + // cached tracks for this server rather than replacing them with a + // single placeholder — otherwise an interrupted refresh would wipe + // the user's library and cascade into an empty browser view. + if (files === null) { + if (cachedServerTracks.length) return cachedServerTracks; - const trackId = cachedTrack?.id || TID.now(); - const stats = cachedTrack?.stats; - const tags = cachedTrack?.tags; + const now = new Date().toISOString(); + return [/** @type {Track} */ ({ + $type: "sh.diffuse.output.track", + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri: buildURI(server), + })]; + } + + let tracks = files + .filter((path) => isAudioFile(path)) + .map((path) => { + const cachedTrack = cache[id]?.[safeDecodeURIComponent(path)]; + + const trackId = cachedTrack?.id || TID.now(); + const stats = cachedTrack?.stats; + const tags = cachedTrack?.tags; + const now = new Date().toISOString(); + + /** @type {Track} */ + const track = { + $type: "sh.diffuse.output.track", + id: trackId, + createdAt: cachedTrack?.createdAt ?? now, + updatedAt: cachedTrack?.updatedAt ?? now, + stats, + tags, + uri: buildURI(server, path), + }; + + return track; + }); + + if (!tracks.length) { const now = new Date().toISOString(); - /** @type {Track} */ - const track = { + tracks = [{ $type: "sh.diffuse.output.track", - id: trackId, - createdAt: cachedTrack?.createdAt ?? now, - updatedAt: cachedTrack?.updatedAt ?? now, - stats, - tags, - uri: buildURI(server, path), - }; - - return track; - }); - - if (!tracks.length) { - const now = new Date().toISOString(); - - tracks = [{ - $type: "sh.diffuse.output.track", - id: TID.now(), - createdAt: now, - updatedAt: now, - kind: "placeholder", - uri: buildURI(server), - }]; - } - - return tracks; - }); + id: TID.now(), + createdAt: now, + updatedAt: now, + kind: "placeholder", + uri: buildURI(server), + }]; + } + + return tracks; + }, + ); return (await Promise.all(promises)).flat(1); } diff --git a/src/components/input/https/common.js b/src/components/input/https/common.js index b1bc062f..3899038d 100644 --- a/src/components/input/https/common.js +++ b/src/components/input/https/common.js @@ -239,19 +239,25 @@ export function parseURI(uriString) { } } -/** @param {string} uri */ +/** + * @param {string} uri + * @returns {Promise} + */ async function consultHost(uri) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); const response = await fetch(uri, { method: "HEAD", signal: controller.signal, }); clearTimeout(timeoutId); - return response.ok; + return response.ok ? "yes" : "no"; } catch { - return false; + // Network/timeout error: inconclusive — let `cachedConsult` fall + // back to the last known value rather than caching a sticky "no". + clearTimeout(timeoutId); + return "unsure"; } } diff --git a/src/components/input/https/worker.js b/src/components/input/https/worker.js index f24b177a..8610cb1e 100644 --- a/src/components/input/https/worker.js +++ b/src/components/input/https/worker.js @@ -72,10 +72,12 @@ export async function groupConsult(uris) { const promises = Object.entries(groups).map( async ([_domainId, { host, uris }]) => { const testUri = uris[0]; - const available = testUri ? await consultHostCached(testUri) : false; + const available = /** @type {import("@specs/components/input/types.d.ts").ConsultResult} */ ( + testUri ? await consultHostCached(testUri) : "no" + ); /** @type {ConsultGrouping} */ - const grouping = available + const grouping = available === "yes" ? { available, scheme: SCHEME, uris } : { available, reason: "Host unreachable", scheme: SCHEME, uris }; diff --git a/src/components/input/icecast/common.js b/src/components/input/icecast/common.js index 11ba3988..28b5b73d 100644 --- a/src/components/input/icecast/common.js +++ b/src/components/input/icecast/common.js @@ -188,12 +188,18 @@ export async function fetchMetadata(streamUrl) { } } -/** @param {string} uri */ +/** + * @param {string} uri + * @returns {Promise} + */ async function consultStream(uri) { const parsed = parseURI(uri); - if (!parsed) return false; + if (!parsed) return "no"; const metadata = await fetchMetadata(parsed.streamUrl); - return metadata !== undefined; + // `fetchMetadata` swallows transport/parse errors as `undefined`; + // treat that as inconclusive so a network blip doesn't hide the + // stream for the full consult TTL. + return metadata === undefined ? "unsure" : "yes"; } export const consultStreamCached = cachedConsult( diff --git a/src/components/input/icecast/worker.js b/src/components/input/icecast/worker.js index 03d793f2..02569579 100644 --- a/src/components/input/icecast/worker.js +++ b/src/components/input/icecast/worker.js @@ -71,10 +71,12 @@ export async function groupConsult(uris) { const promises = Object.entries(groups).map( async ([_hostId, { host, uris }]) => { const testUri = uris[0]; - const available = testUri ? await consultStreamCached(testUri) : false; + const available = /** @type {import("@specs/components/input/types.d.ts").ConsultResult} */ ( + testUri ? await consultStreamCached(testUri) : "no" + ); /** @type {ConsultGrouping} */ - const grouping = available + const grouping = available === "yes" ? { available, scheme: SCHEME, uris } : { available, reason: "Stream unreachable", scheme: SCHEME, uris }; diff --git a/src/components/input/local/worker.js b/src/components/input/local/worker.js index 7d1bd05b..c0dc385e 100644 --- a/src/components/input/local/worker.js +++ b/src/components/input/local/worker.js @@ -54,7 +54,7 @@ export async function consult(fileUriOrScheme) { mode: "read", }); - return { supported: true, consult: permission === "granted" }; + return { supported: true, consult: permission === "granted" ? "yes" : "no" }; } /** @@ -98,15 +98,14 @@ export async function groupConsult(uris) { const available = (await /** @type {any} */ (handle).queryPermission({ mode: "read" })) === - "granted"; + "granted" + ? /** @type {const} */ ("yes") + : /** @type {const} */ ("no"); /** @type {ConsultGrouping} */ - const grouping = available ? { available, scheme: SCHEME, uris } : { - available: false, - reason: "Permission not granted", - scheme: SCHEME, - uris, - }; + const grouping = available === "yes" + ? { available, scheme: SCHEME, uris } + : { available, reason: "Permission not granted", scheme: SCHEME, uris }; return [{ key: groupKey(SCHEME, tid), grouping }]; }); diff --git a/src/components/input/opensubsonic/common.js b/src/components/input/opensubsonic/common.js index a608803b..30b2242b 100644 --- a/src/components/input/opensubsonic/common.js +++ b/src/components/input/opensubsonic/common.js @@ -55,11 +55,19 @@ export function buildURI(server, args) { /** * @param {Server} server + * @returns {Promise} */ export async function consultServer(server) { const client = createClient(server); - const resp = await client.ping().catch(() => undefined); - return resp?.status?.toLowerCase() === "ok"; + let resp; + try { + resp = await client.ping(); + } catch { + // Transport error (network blip, timeout): inconclusive — don't + // cache a sticky "no" for the full consult TTL. + return "unsure"; + } + return resp?.status?.toLowerCase() === "ok" ? "yes" : "no"; } export const consultServerCached = cachedConsult(consultServer, serverId); diff --git a/src/components/input/opensubsonic/worker.js b/src/components/input/opensubsonic/worker.js index 73a5ddb9..c9e0d708 100644 --- a/src/components/input/opensubsonic/worker.js +++ b/src/components/input/opensubsonic/worker.js @@ -94,7 +94,7 @@ export async function groupConsult(uris) { const available = await consultServerCached(server); /** @type {ConsultGrouping} */ - const grouping = available + const grouping = available === "yes" ? { available, scheme: SCHEME, uris } : { available, reason: "Server ping failed", scheme: SCHEME, uris }; diff --git a/src/components/input/s3/common.js b/src/components/input/s3/common.js index 677c1ab9..2bcd165c 100644 --- a/src/components/input/s3/common.js +++ b/src/components/input/s3/common.js @@ -63,10 +63,18 @@ export function buildURI(bucket, path) { /** * @param {Bucket} bucket + * @returns {Promise} */ export async function consultBucket(bucket) { const client = createClient(bucket); - return await client.bucketExists(bucket.bucketName); + try { + const exists = await client.bucketExists(bucket.bucketName); + return exists ? "yes" : "no"; + } catch { + // Network/credentials/transport error: inconclusive — don't let a + // transient blip flip this bucket to "no" for the full consult TTL. + return "unsure"; + } } export const consultBucketCached = cachedConsult(consultBucket, bucketId); diff --git a/src/components/input/s3/worker.js b/src/components/input/s3/worker.js index 981b40e9..374dfd9e 100644 --- a/src/components/input/s3/worker.js +++ b/src/components/input/s3/worker.js @@ -81,7 +81,7 @@ export async function groupConsult(uris) { const available = await consultBucketCached(bucket); /** @type {ConsultGrouping} */ - const grouping = available + const grouping = available === "yes" ? { available, scheme: SCHEME, uris } : { available, reason: "Bucket unavailable", scheme: SCHEME, uris }; diff --git a/src/components/input/webdav/common.js b/src/components/input/webdav/common.js index daaaf6bf..3ac14962 100644 --- a/src/components/input/webdav/common.js +++ b/src/components/input/webdav/common.js @@ -182,13 +182,14 @@ export function groupUrisByServer(uris) { /** * @param {Server} server + * @returns {Promise} */ async function checkAccess(server) { - try { - const url = toHttpUrl(server, server.dir); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + const url = toHttpUrl(server, server.dir); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + try { const response = await fetch(url, { method: "PROPFIND", headers: { @@ -199,9 +200,10 @@ async function checkAccess(server) { }); clearTimeout(timeoutId); - return response.status === 207 || response.ok; + return (response.status === 207 || response.ok) ? "yes" : "no"; } catch { - return false; + clearTimeout(timeoutId); + return "unsure"; } } diff --git a/src/components/input/webdav/worker.js b/src/components/input/webdav/worker.js index a391840b..a7232ac9 100644 --- a/src/components/input/webdav/worker.js +++ b/src/components/input/webdav/worker.js @@ -83,7 +83,7 @@ export async function groupConsult(uris) { const available = await checkAccessCached(server); /** @type {ConsultGrouping} */ - const grouping = available + const grouping = available === "yes" ? { available, scheme: SCHEME, uris } : { available, reason: "WebDAV server unreachable", scheme: SCHEME, uris }; diff --git a/src/components/orchestrator/scoped-tracks/element.js b/src/components/orchestrator/scoped-tracks/element.js index 31a187ec..ad2d6149 100644 --- a/src/components/orchestrator/scoped-tracks/element.js +++ b/src/components/orchestrator/scoped-tracks/element.js @@ -193,7 +193,15 @@ class ScopedTracksOrchestrator extends BroadcastableDiffuseElement { const availableUris = new Set(); Object.values(groups).forEach((value) => { - if (value.available === false) return; + // Only include tracks whose source confirmed availability ("yes"). + // Both "no" (server explicitly rejected) and "unsure" (transient + // consult failure — e.g. a laptop waking up before the network + // is fully back) hide the source's tracks until a real consult + // succeeds. `cachedConsult` normalises "unsure" to "no" for callers + // (without caching it), so a brief blip doesn't pin availability + // to "unavailable" for the full TTL — the next consult retries + // immediately and repopulates the browser once the server responds. + if (value.available !== "yes") return; for (const uri of value.uris) { availableUris.add(uri); } diff --git a/src/facets/data/sources/index.inline.js b/src/facets/data/sources/index.inline.js index 7024e5f9..421e5d8d 100644 --- a/src/facets/data/sources/index.inline.js +++ b/src/facets/data/sources/index.inline.js @@ -106,9 +106,13 @@ async function checkOnlineStatus(sourcesRecord) { const entries = await Promise.all( sources.map(async ({ uri }) => { const result = await inputConfigurator.consult(uri); + // `cachedConsult` normalises `"unsure"` (transient consult failure) + // to `"no"` without caching it — so a brief network blip after laptop + // wake shows "Offline" transiently and flips back to "Online" on the + // next consult, rather than sticking "Offline" for the full TTL. const online = result.supported && result.consult !== "undetermined" - ? result.consult + ? result.consult === "yes" : null; return /** @type {[string, boolean | null]} */ ([trackPrefix(uri), online]); }),