diff --git a/src/backend/common/vendor/JRiverApiClient.ts b/src/backend/common/vendor/JRiverApiClient.ts index 1a7cab90..fbc515ad 100644 --- a/src/backend/common/vendor/JRiverApiClient.ts +++ b/src/backend/common/vendor/JRiverApiClient.ts @@ -4,6 +4,7 @@ import xml2js from 'xml2js'; import { type AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../infrastructure/Atomic.ts"; import type {JRiverData} from "../infrastructure/config/source/jriver.ts"; import AbstractApiClient from "./AbstractApiClient.ts"; +import { AuthError } from '../errors/MSErrors.ts'; const parser = new xml2js.Parser({'async': true}); @@ -155,7 +156,7 @@ export class JRiverApiClient extends AbstractApiClient { if(this.config.username === undefined || this.config.password === undefined) { msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?'; } - throw new Error(msg, {cause: e}); + throw new AuthError(msg, {cause: e}); } } diff --git a/src/backend/common/vendor/KodiApiClient.ts b/src/backend/common/vendor/KodiApiClient.ts index de3b12d6..a2013d5f 100644 --- a/src/backend/common/vendor/KodiApiClient.ts +++ b/src/backend/common/vendor/KodiApiClient.ts @@ -9,6 +9,7 @@ import type {KodiData} from "../infrastructure/config/source/kodi.ts"; import AbstractApiClient from "./AbstractApiClient.ts"; import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts"; import { artistNamesToCredits } from "../../../core/StringUtils.ts"; +import { AuthError } from "../errors/MSErrors.ts"; interface KodiDuration { hours: number @@ -150,7 +151,7 @@ export class KodiApiClient extends AbstractApiClient { if(this.config.username === undefined || this.config.password === undefined) { msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?'; } - throw new Error(msg, {cause: e}); + throw new AuthError(msg, {cause: e}); } } diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index e8b4292a..6c2c78ce 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -17,10 +17,11 @@ import { LastFMUser, LastFMAuth, LastFMTrack, type LastFMUserGetRecentTracksResp import clone from 'clone'; import type { IncomingMessage } from "http"; import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts"; -import { ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts"; +import { AuthError, ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts"; import { redactString } from "@foxxmd/redact-string"; import dns from 'node:dns/promises'; import xml2js from 'xml2js'; +import { findCauseByFunc } from "../../utils/ErrorUtils.ts"; const badErrors = [ 'api key suspended', @@ -272,8 +273,15 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina this.logger.error('Testing auth failed'); if(isNodeNetworkException(e)) { this.logger.error(`Could not communicate with ${this.upstreamName} API`); + throw new AuthError('Testing auth failed', {cause: e, unrecoverable: false}); } - throw e; + let unrecoverable: boolean; + const errorWithMessage = findCauseByFunc(e, (ee) => `response` in ee) as Error & {response: IncomingMessage} | undefined; + if(errorWithMessage !== undefined) { + unrecoverable = [401,403].includes(errorWithMessage.response.statusCode); + } + // TODO maybe check if error has actual LFM response content with error code? + throw new AuthError('Testing auth failed', {cause: e, unrecoverable}); } } diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 1e6cc77d..a077c0a8 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -23,7 +23,7 @@ import { unique } from '../../utils.ts'; import { removeUndefinedKeys } from '../../../core/DataUtils.ts'; import type {ListenPayload, ListenResponse, ListenType, SubmitPayload} from '../../../core/vendor/listenbrainz/interfaces.ts'; import { baseFormatPlayObj } from '../../utils/PlayTransformUtils.ts'; -import { ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.ts'; +import { AuthError, ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.ts'; import pRetry from 'p-retry'; import { findCauseByFunc } from '../../utils/ErrorUtils.ts'; import { isSuperAgentResponseError } from '../errors/ErrorUtils.ts'; @@ -183,8 +183,12 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless try { const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url,'1/validate-token')}`)); return true; - } catch (e) { - throw e; + } catch (err) { + const cause = findCauseByFunc(err, (e) => isSuperAgentResponseError(e)); + if(cause !== undefined && [401,403,400].includes(cause.status)) { + throw new AuthError('Failed to validate token', {cause: err, unrecoverable: true}); + } + throw new AuthError('Failed to validate token due to non-auth error', {cause: err, unrecoverable: false}); } } diff --git a/src/backend/common/vendor/RockSkyApiClient.ts b/src/backend/common/vendor/RockSkyApiClient.ts index 09b971e1..a9d4a547 100644 --- a/src/backend/common/vendor/RockSkyApiClient.ts +++ b/src/backend/common/vendor/RockSkyApiClient.ts @@ -14,7 +14,7 @@ import type {RockskyScrobble} from './rocksky/interfaces.ts'; import type {Handle} from "@atcute/lexicons"; import { getATProtoIdentifier, identifierToAtProtoHandle } from './atproto/atUtils.ts'; import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts"; -import { ScrobbleSubmitError } from "../errors/MSErrors.ts"; +import { AuthError, ScrobbleSubmitError } from "../errors/MSErrors.ts"; import { tryApiCall } from "../../utils/RequestUtils.ts"; import { type CreateScrobbleInput, RockskyClient } from "@rocksky/sdk"; import { getRoot } from "../../ioc.ts"; @@ -23,6 +23,8 @@ import type {HandleData} from "../infrastructure/config/client/atproto.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { removeUndefinedKeys } from "../../../core/DataUtils.ts"; import { isrcNoHyphens } from '../../../core/PlayUtils.ts'; +import { findCauseByFunc } from "../../utils/ErrorUtils.ts"; +import { isSuperAgentResponseError } from "../errors/ErrorUtils.ts"; interface SubmitOptions { log?: boolean @@ -166,7 +168,8 @@ export class RockSkyApiClient extends AbstractApiClient { const resp = await this.callLZApi(() => request.get(`${joinedUrl(this.lzUrl.url,'1/validate-token')}`)); return true; } catch (e) { - throw e; + const cause = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); + throw new AuthError('Failed to validate token', {cause: e, unrecoverable: cause !== undefined && [401,403].includes(cause.status)}); } } else { try { @@ -174,7 +177,9 @@ export class RockSkyApiClient extends AbstractApiClient { await req; return true; } catch (e) { - throw new UpstreamError('Failed to get /profile with given token', {cause: e}); + const upstreamErr = new UpstreamError('Failed to get /profile with given token', {cause: e}); + const cause = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); + throw new AuthError('Failed to get /profile with given token', {cause: upstreamErr, unrecoverable: cause !== undefined && [401,403].includes(cause.status)}); } } } diff --git a/src/backend/common/vendor/koito/KoitoApiClient.ts b/src/backend/common/vendor/koito/KoitoApiClient.ts index 6285fd00..c4f4cebf 100644 --- a/src/backend/common/vendor/koito/KoitoApiClient.ts +++ b/src/backend/common/vendor/koito/KoitoApiClient.ts @@ -11,10 +11,12 @@ import { playToListenPayload } from '../listenbrainz/lzUtils.ts'; import type {SubmitPayload} from '../../../../core/vendor/listenbrainz/interfaces.ts'; import type {ListenType} from '../../../../core/vendor/listenbrainz/interfaces.ts'; import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts"; -import { ScrobbleSubmitError } from "../../errors/MSErrors.ts"; +import { AuthError, ScrobbleSubmitError } from "../../errors/MSErrors.ts"; import { tryApiCall } from "../../../utils/RequestUtils.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { artistNamesToCredits } from "../../../../core/StringUtils.ts"; +import { findCauseByFunc } from "../../../utils/ErrorUtils.ts"; +import { isSuperAgentResponseError } from "../../errors/ErrorUtils.ts"; interface SubmitOptions { log?: boolean @@ -133,7 +135,8 @@ export class KoitoApiClient extends AbstractApiClient implements PaginatedTimeRa const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/validate-token')}`)); return true; } catch (e) { - throw new Error('Could not validate Koito API Key', { cause: e }); + const superagentError = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); + throw new AuthError('Could not validate Koito API Key', { cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status)}); } } diff --git a/src/backend/common/vendor/maloja/MalojaApiClient.ts b/src/backend/common/vendor/maloja/MalojaApiClient.ts index e1eb7bce..5b6a3df9 100644 --- a/src/backend/common/vendor/maloja/MalojaApiClient.ts +++ b/src/backend/common/vendor/maloja/MalojaApiClient.ts @@ -16,8 +16,9 @@ import { getMalojaResponseError, isMalojaAPIErrorBody, type MalojaResponseV3Comm import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.ts'; import { artistCreditsToNames, artistNamesToCredits, buildTrackString } from '../../../../core/StringUtils.ts'; import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.ts'; -import { ScrobbleSubmitError } from '../../errors/MSErrors.ts'; +import { AuthError, ScrobbleSubmitError } from '../../errors/MSErrors.ts'; import { NO_RETRY_HTTP_STATUS, tryApiCall } from '../../../utils/RequestUtils.ts'; +import { findCauseByFunc } from '../../../utils/ErrorUtils.ts'; @@ -175,10 +176,11 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR body, text: text.slice(0, 50) },'Maloja API Response'); - throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new Error(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) }) + throw new UpstreamError('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new UpstreamError(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) }) } } catch (e) { - throw e; + const superagentError = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); + throw new AuthError('Failed to test Maloja API with apikey', {cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status)}); } } diff --git a/src/backend/scrobblers/MalojaScrobbler.ts b/src/backend/scrobblers/MalojaScrobbler.ts index c8f5a3bb..044f905b 100644 --- a/src/backend/scrobblers/MalojaScrobbler.ts +++ b/src/backend/scrobblers/MalojaScrobbler.ts @@ -1,7 +1,7 @@ import { childLogger, type Logger } from "@foxxmd/logging"; import type EventEmitter from "events"; import normalizeUrl from "normalize-url"; -import type {PlayObject} from "../../core/Atomic.ts"; +import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject} from "../../core/Atomic.ts"; import { buildTrackString, capitalize } from "../../core/StringUtils.ts"; import { isNodeNetworkException } from "../common/errors/NodeErrors.ts"; import type {FormatPlayObjectOptions, TimeRangeListensFetcher} from "../common/infrastructure/Atomic.ts"; @@ -15,6 +15,7 @@ const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", " export default class MalojaScrobbler extends AbstractScrobbleClient { + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; requiresAuth = true; serverVersion: any; webUrl: string; diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index 8b06d28a..4322768e 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -25,6 +25,9 @@ import { fromStream } from '@atcute/repo'; import { playToRepositoryCreatePlayHistoricalOpts, type RepositoryCreatePlayHistoricalOpts } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts"; import { isAbortError } from "abort-controller-x"; import type { FmTealAlphaFeedPlay, FmTealFeedPlay } from "../common/vendor/teal/lexicons/index.ts"; +import { AuthError } from "../common/errors/MSErrors.ts"; +import { findCauseByReference } from "../utils/ErrorUtils.ts"; +import { ClientResponseError } from "@atcute/client"; export default class TealScrobbler extends AbstractHistoricalScrobbleClient { @@ -98,10 +101,14 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { return res; } } catch (e) { - if(isNodeNetworkException(e)) { + const nodeNetError = isNodeNetworkException(e); + if(nodeNetError) { this.logger.error('Could not communicate with ATProto API'); + throw new AuthError(`Failed to validate session due to network issues`, {cause: e, unrecoverable: false}); } - throw e; + const clientError = findCauseByReference(e, ClientResponseError); + const authIssue = clientError !== undefined && [401,403].includes(clientError.status); + throw new AuthError(`Failed to validate session${!authIssue ? ' due to network issues' : ''}`, {cause: e, unrecoverable: authIssue}); } } diff --git a/src/backend/sources/JRiverSource.ts b/src/backend/sources/JRiverSource.ts index e9df9dbd..68fd7a1b 100644 --- a/src/backend/sources/JRiverSource.ts +++ b/src/backend/sources/JRiverSource.ts @@ -2,7 +2,7 @@ import dayjs from "dayjs"; import type { EventEmitter } from "events"; import normalizeUrl from 'normalize-url'; import { URL } from "url"; -import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; +import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type PlayObjectMinimal} from "../../core/Atomic.ts"; import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts"; import type {JRiverSourceConfig} from "../common/infrastructure/config/source/jriver.ts"; import { type Info, JRiverApiClient, PLAYER_STATE } from "../common/vendor/JRiverApiClient.ts"; @@ -18,6 +18,7 @@ export class JRiverSource extends MemoryPositionalSource { client: JRiverApiClient; clientReady: boolean = false; + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; constructor(name: any, config: JRiverSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { diff --git a/src/backend/sources/JellyfinApiSource.ts b/src/backend/sources/JellyfinApiSource.ts index fbd3f853..3f11b229 100644 --- a/src/backend/sources/JellyfinApiSource.ts +++ b/src/backend/sources/JellyfinApiSource.ts @@ -37,11 +37,11 @@ import { import dayjs from "dayjs"; import type EventEmitter from "events"; import { FixedSizeList } from "fixed-size-list"; -import type {ArtistCredit, BrainzMeta, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; +import type {ArtistCredit, BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; import { genGroupIdStr } from '../../core/PlayUtils.ts'; import { artistNameToCredit, buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts"; import type {FormatPlayObjectOptions, InternalConfig, PlayerStateDataMaybePlay} from "../common/infrastructure/Atomic.ts"; -import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; +import { COMPONENT_AUTH_TYPE, REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {JellyApiSourceConfig} from "../common/infrastructure/config/source/jellyfin.ts"; import { getPlatformIdFromData, isDebugMode } from "../utils.ts"; import { noCasePropObj } from "../utils/DataUtils.ts"; @@ -49,6 +49,8 @@ import { joinedUrl } from "../utils/NetworkUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.ts"; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; +import * as axios from 'axios'; +import { AuthError } from "../common/errors/MSErrors.ts"; const shortDeviceId = truncateStringToLength(10, ''); @@ -81,6 +83,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource { libraries: {name: string, paths: string[], collectionType: CollectionType}[] = []; declare config: JellyApiSourceConfig; + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; constructor(name: any, config: JellyApiSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('jellyfin', name, config, internal, emitter); @@ -251,7 +254,8 @@ export default class JellyfinApiSource extends MemoryPositionalSource { } return true; } catch (e) { - throw e; + const unrecoverable = axios.isAxiosError(e) && [401,403].includes(e.status); + throw new AuthError('API Key failed to authenticate', {cause: e, unrecoverable}); } } diff --git a/src/backend/sources/KodiSource.ts b/src/backend/sources/KodiSource.ts index 213d4b97..3f65391c 100644 --- a/src/backend/sources/KodiSource.ts +++ b/src/backend/sources/KodiSource.ts @@ -1,5 +1,5 @@ import type { EventEmitter } from "events"; -import type {PlayObject} from "../../core/Atomic.ts"; +import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject} from "../../core/Atomic.ts"; import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts"; import type {KodiSourceConfig} from "../common/infrastructure/config/source/kodi.ts"; import { KodiApiClient } from "../common/vendor/KodiApiClient.ts"; @@ -11,6 +11,7 @@ export class KodiSource extends MemoryPositionalSource { client: KodiApiClient; clientReady: boolean = false; + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; constructor(name: any, config: KodiSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index f6c49794..ac26974d 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -2,13 +2,13 @@ import dayjs from "dayjs"; import type { EventEmitter } from "events"; import path from 'path'; import {MPC, type Status, type Song, type PlaylistItem} from 'mpc-js'; -import type {BrainzMeta, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; +import type {BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateData, } from "../common/infrastructure/Atomic.ts"; -import { SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; +import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {ReportedPlayerStatus} from '../../core/Atomic.ts'; import type {MPDSourceConfig, PlayerState} from "../common/infrastructure/config/source/mpd.ts"; @@ -18,6 +18,7 @@ import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { isDebugMode, sleep } from "../utils.ts"; import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { AuthError } from "../common/errors/MSErrors.ts"; const CLIENT_PLAYER_STATE: Record = { 'play': REPORTED_PLAYER_STATUSES.playing, @@ -36,6 +37,8 @@ export class MPDSource extends MemoryPositionalSource { protected currentPlayPath: string; protected currentPlaySong?: Song; + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; + constructor(name: any, config: MPDSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data = {} @@ -139,7 +142,7 @@ export class MPDSource extends MemoryPositionalSource { // break; // } // } - throw new Error(`Could not connect to MPD server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e}); + throw new AuthError(`Could not connect to MPD server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e, unrecoverable: false}); } } diff --git a/src/backend/sources/MusikcubeSource.ts b/src/backend/sources/MusikcubeSource.ts index 3ff8cb22..52ab8fc3 100644 --- a/src/backend/sources/MusikcubeSource.ts +++ b/src/backend/sources/MusikcubeSource.ts @@ -4,14 +4,14 @@ import type { CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'; import { WS } from 'iso-websocket' import { randomUUID } from "node:crypto"; import pEvent from 'p-event'; -import type {PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts"; +import type {ComponentAuthType, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts"; import { UpstreamError } from "../common/errors/UpstreamError.ts"; import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateData, } from "../common/infrastructure/Atomic.ts"; -import { SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; +import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; import type {MCAuthenticateRequest, MCAuthenticateResponse, MCPlaybackOverviewRequest, MCPlaybackOverviewResponse, MusikcubeSourceConfig} from "../common/infrastructure/config/source/musikcube.ts"; import { sleep } from "../utils.ts"; import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; @@ -19,6 +19,7 @@ import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { normalizeWSAddress } from "../utils/NetworkUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { AuthError } from "../common/errors/MSErrors.ts"; const CLIENT_STATE = { 0: 'connecting', @@ -29,6 +30,7 @@ const CLIENT_STATE = { export class MusikcubeSource extends MemoryPositionalSource { declare config: MusikcubeSourceConfig; + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; url: URLData; @@ -149,9 +151,9 @@ export class MusikcubeSource extends MemoryPositionalSource { if(authE === undefined) { throw new Error('Musikcube did not respond to auth message after 2000 ms'); } else if(isCloseEvent(authE)) { - throw new Error(`Password is not correct: ${authE.code} => ${authE.reason}`); + throw new AuthError(`Password is not correct: ${authE.code} => ${authE.reason}`, {unrecoverable: true}); } else if(isErrorEvent(authE)) { - throw new Error(`Unexpected error occurred while authenticating: ${authE.message}`, {cause: authE.error}); + throw new AuthError(`Unexpected error occurred while authenticating: ${authE.message}`, {cause: authE.error, unrecoverable: false}); } return true; diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index bd5a2660..705bb921 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -40,7 +40,7 @@ import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts"; -import { SimpleError } from "../common/errors/MSErrors.ts"; +import { AuthError, SimpleError } from "../common/errors/MSErrors.ts"; const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position']; const state = 'random'; @@ -351,7 +351,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag if(isNodeNetworkException(e)) { this.logger.error('Could not communicate with Spotify API'); } - throw e; + throw new AuthError('Failed to authenticate', {cause: e, unrecoverable: 'statusCode' in e && [401,403].includes(e.statusCode)}) } } diff --git a/src/backend/sources/SubsonicSource.ts b/src/backend/sources/SubsonicSource.ts index 6f199fec..2c5d49c4 100644 --- a/src/backend/sources/SubsonicSource.ts +++ b/src/backend/sources/SubsonicSource.ts @@ -4,11 +4,11 @@ import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"; import type EventEmitter from "events"; import type { Request } from 'superagent'; import request from 'superagent'; -import { REPORTED_PLAYER_STATUSES, type PlayObject, type PlayObjectMinimal } from "../../core/Atomic.ts"; +import { COMPONENT_AUTH_TYPE, REPORTED_PLAYER_STATUSES, type PlayObject, type PlayObjectMinimal } from "../../core/Atomic.ts"; import { isNodeNetworkException } from "../common/errors/NodeErrors.ts"; import { UpstreamError } from "../common/errors/UpstreamError.ts"; import { DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfig, type PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.ts"; -import type {PlayPlatformId} from '../../core/Atomic.ts'; +import type {ComponentAuthType, PlayPlatformId} from '../../core/Atomic.ts'; import type {SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.ts"; import { getSubsonicResponse, type EntryData, type OpenSubsonicExtensionsResponse, type SubsonicNowPlayingResponse, type SubsonicResponse, type SubsonicResponseCommon } from "../common/vendor/subsonic/interfaces.ts"; import { removeDuplicates } from "../utils.ts"; @@ -22,7 +22,9 @@ import { baseFormatPlayObj } from '../utils/PlayTransformUtils.ts'; import { noRetryOnUpstreamError, tryApiCall } from '../utils/RequestUtils.ts'; import { artistNameToCredit } from '../../core/StringUtils.ts'; import { timeToHumanTimestamp, todayAwareFormat } from '../../core/TimeUtils.ts'; -import type { ComponentSourceApiJson, SubsonicSourceApiJson } from '../../core/Api.ts'; +import type { SubsonicSourceApiJson } from '../../core/Api.ts'; +import { isSuperAgentResponseError } from '../common/errors/ErrorUtils.ts'; +import { AuthError } from '../common/errors/MSErrors.ts'; dayjs.extend(isSameOrAfter); @@ -39,6 +41,7 @@ interface SourceIdentifierData { export class SubsonicSource extends MemoryPositionalSource { requiresAuth = true; + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; multiPlatform: boolean = true; @@ -341,7 +344,8 @@ export class SubsonicSource extends MemoryPositionalSource { this.logger.info('Subsonic API Status: ok'); return true; } catch (e) { - throw e; + const superagentError = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); + throw new AuthError('Failed to authenticate', {cause: e, unrecoverable: superagentError !== undefined && [403,401].includes(superagentError.status)}) } } diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts index 72a35de1..f3842538 100644 --- a/src/backend/sources/VLCSource.ts +++ b/src/backend/sources/VLCSource.ts @@ -2,13 +2,13 @@ import { parseRegexSingle, parseToRegex } from "@foxxmd/regex-buddy-core"; import type { EventEmitter } from "events"; import * as VLC from "vlc-client" import type {VlcMeta, VlcStatus} from "vlc-client/dist/Types.js"; -import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; +import type {ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateData, } from "../common/infrastructure/Atomic.ts"; -import { SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; +import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts'; import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {ReportedPlayerStatus} from '../../core/Atomic.ts'; import type {VlcAudioMeta, VLCSourceConfig, PlayerState} from "../common/infrastructure/config/source/vlc.ts"; @@ -19,6 +19,7 @@ import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { isDebugMode } from "../utils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { artistNamesToCredits } from "../../core/StringUtils.ts"; +import { AuthError } from "../common/errors/MSErrors.ts"; const CLIENT_PLAYER_STATE: Record = { 'playing': REPORTED_PLAYER_STATUSES.playing, @@ -28,6 +29,7 @@ const CLIENT_PLAYER_STATE: Record = { export class VLCSource extends MemoryPositionalSource { declare config: VLCSourceConfig; + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; host?: string port?: number @@ -117,7 +119,7 @@ export class VLCSource extends MemoryPositionalSource { return true; } catch (e) { let friendlyError: string | undefined; - throw new Error(`Could not connect to VLC server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e}); + throw new AuthError(`Could not connect to VLC server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e}); } } diff --git a/src/backend/tests/scrobbler/TestScrobbler.ts b/src/backend/tests/scrobbler/TestScrobbler.ts index f0c96795..5dc5988b 100644 --- a/src/backend/tests/scrobbler/TestScrobbler.ts +++ b/src/backend/tests/scrobbler/TestScrobbler.ts @@ -1,6 +1,6 @@ import EventEmitter from "events"; import request from "superagent"; -import type {PlayObject} from "../../../core/Atomic.ts"; +import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject} from "../../../core/Atomic.ts"; import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient.ts"; import type {CommonClientConfig, CommonClientOptions, NowPlayingOptions} from "../../common/infrastructure/config/client/index.ts"; import clone from "clone"; @@ -12,6 +12,8 @@ import type { DrizzleQueueRepository } from "../../common/database/drizzle/repos import type {PlaySelect} from "../../common/database/drizzle/drizzleTypes.ts"; import dayjs from "dayjs"; import type { MarkOptional, MarkRequired } from "ts-essentials"; +import { AuthError } from "../../common/errors/MSErrors.ts"; +import { isSuperAgentResponseError } from "../../common/errors/ErrorUtils.ts"; export class TestScrobbler extends AbstractScrobbleClient { @@ -60,8 +62,11 @@ export class TestScrobbler extends AbstractScrobbleClient { } export class TestAuthScrobbler extends TestScrobbler { + override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended; + constructor() { super(); + this.requiresAuth = true; } doAuthentication = async() => { @@ -69,7 +74,7 @@ export class TestAuthScrobbler extends TestScrobbler { await request.get('http://example.com'); return true; } catch (e) { - throw e; + throw new AuthError('Failed to auth', {cause: e, unrecoverable: isSuperAgentResponseError(e) && [401,403].includes(e.status)}); } } } diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index e3761997..c801d37b 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -91,10 +91,10 @@ describe('Networking', function () { async function() { const authScrobbler = new TestAuthScrobbler(); try { - await authScrobbler.testAuth(); + await authScrobbler.initialize(); } catch (e) { /* empty */ } assert.isTrue(authScrobbler.authGated()); - assert.isFalse(authScrobbler.authFailure); + assert.isFalse(authScrobbler.hasUnrecoverableAuthFailure()); } )); @@ -109,10 +109,10 @@ describe('Networking', function () { async function() { const authScrobbler = new TestAuthScrobbler(); try { - await authScrobbler.testAuth(); + await authScrobbler.initialize(); } catch (e) { /* empty */ } assert.isTrue(authScrobbler.authGated()); - assert.isTrue(authScrobbler.authFailure); + assert.isTrue(authScrobbler.hasUnrecoverableAuthFailure()); } )); }); diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index fae76e5f..d5f07d66 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import type {ComponentClientApi, ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentSourceApi, ComponentSourceApiJson, ComponentState, PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi} from "../../Api.ts"; -import { CLIENT_INGRESS_QUEUE, type ComponentType, type JsonPlayObject, type PlayObject, QUEUE_STATUSES, type SourcePlayerJson, sourceSotTypes } from "../../Atomic.ts"; +import { CLIENT_INGRESS_QUEUE, COMPONENT_AUTH_TYPE, type ComponentType, type JsonPlayObject, type PlayObject, QUEUE_STATUSES, type SourcePlayerJson, sourceSotTypes } from "../../Atomic.ts"; import { generatePlay, normalizePlays } from "./PlayTestUtils.ts"; import { generatePlayInput, generatePlayWithLifecycle, playWithLifecycleScrobble, randomPlayState } from "./fixtures.ts"; import { asJsonPlayObject } from "../../PlayMarshalUtils.ts"; @@ -172,7 +172,12 @@ export const generateSourceApiJson = (data: Partial = {}): C supportsUpstreamRecentlyPlayed, tracksDiscovered, players, - sleeping + sleeping, + initialized: true, + authType: COMPONENT_AUTH_TYPE.unattended, + hasAuth: true, + hasAuthInteraction: true, + authed: true } } @@ -200,7 +205,12 @@ export const generateClientApiJson = (data: Partial = {}): C deadLetterScrobbles, deadLetterScrobblesTotal, players, - supportsNowPlaying: Object.keys(players).length > 0 + supportsNowPlaying: Object.keys(players).length > 0, + initialized: true, + authType: COMPONENT_AUTH_TYPE.unattended, + hasAuth: true, + hasAuthInteraction: true, + authed: true } }