Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions server/api/simkl/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import SimklAPI, {
SimklRequestError,
simklErrorDetail,
} from '@server/api/simkl';
import axios, { AxiosError, type AxiosAdapter } from 'axios';
import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';

const previousAxiosAdapter = axios.defaults.adapter;

afterEach(() => {
axios.defaults.adapter = previousAxiosAdapter;
});

/** The client builds its axios instance on construction, so stub first. */
const clientAnswering = (status: number, data: unknown) => {
const urls: string[] = [];
axios.defaults.adapter = (async (config) => {
urls.push(String(config.url));
const response = {
status,
statusText: '',
headers: {},
config,
data,
};
if (status < 400) return response;
throw new AxiosError(
`Request failed with status code ${status}`,
AxiosError.ERR_BAD_REQUEST,
config,
undefined,
response
);
}) as AxiosAdapter;
return { client: new SimklAPI({ clientId: 'test-client' }), urls };
};

describe('SimklAPI errors', () => {
it('keeps Simkl’s own reason for a rejected request', async () => {
const { client } = clientAnswering(412, {
error: 'client_id_failed',
code: 412,
message: 'Your client_id is wrong. Try another one',
});
await assert.rejects(client.requestPinCode(), (error: unknown) => {
assert.ok(error instanceof SimklRequestError);
assert.equal(error.status, 412);
assert.equal(
error.message,
'Simkl rejected the request (HTTP 412): client_id_failed — Your client_id is wrong. Try another one'
);
return true;
});
});

it('reduces a proxy’s HTML error page to its text', () => {
assert.equal(
simklErrorDetail(
'<html><body><h1>400 Bad Request</h1>\n<p>Invalid header</p></body></html>'
),
'400 Bad Request Invalid header'
);
assert.equal(simklErrorDetail(''), undefined);
assert.equal(simklErrorDetail({}), undefined);
});

it('caps a long JSON reason like a text one', () => {
assert.equal(
simklErrorDetail({ error: 'bad', message: 'x'.repeat(500) })?.length,
200
);
});

it('validates the Client ID against the PIN endpoint', async () => {
const { client, urls } = clientAnswering(200, {
result: 'OK',
user_code: 'ABCDE',
});
await client.validateClientId();
assert.equal(urls.length, 1);
assert.match(urls[0], /^\/oauth\/pin\?client_id=test-client&/);
});
});
50 changes: 47 additions & 3 deletions server/api/simkl/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,42 @@ export class SimklTemporarilyUnavailableError extends Error {
}
}

/** Simkl (or a proxy on the way) refused the request; keeps its explanation. */
export class SimklRequestError extends Error {
constructor(
public readonly status: number,
detail?: string,
cause?: unknown
) {
super(
`Simkl rejected the request (HTTP ${status})${detail ? `: ${detail}` : ''}`
);
this.name = 'SimklRequestError';
this.cause = cause;
}
}

const SIMKL_ERROR_DETAIL_LIMIT = 200;

/** Simkl answers `{ error, message }`; proxies tend to answer HTML or text. */
export const simklErrorDetail = (data: unknown): string | undefined => {
let text = '';
if (data && typeof data === 'object') {
const { message, error } = data as { message?: unknown; error?: unknown };
text = [error, message]
.filter(
(part): part is string => typeof part === 'string' && part.length > 0
)
.join(' — ');
} else if (typeof data === 'string') {
text = data
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
return text ? text.slice(0, SIMKL_ERROR_DETAIL_LIMIT) : undefined;
};

type SimklOptions = {
clientId?: string;
accessToken?: string;
Expand Down Expand Up @@ -138,14 +174,22 @@ export default class SimklAPI extends ExternalAPI {
}
if (!status || status >= 500)
throw new SimklTemporarilyUnavailableError(error);
throw error;
throw new SimklRequestError(
status,
simklErrorDetail(error.response?.data),
error
);
}
}
}

/** Requires a real Client ID; `/movies/trending` succeeds without one. */
/**
* The PIN endpoint is the only one found to check the Client ID (412
* `client_id_failed`); catalogs and title lookups accept any value. The
* code it issues just expires unused.
*/
public async validateClientId(): Promise<void> {
await this.getCdnCatalog('/discover/trending/tv/week_100.json');
await this.requestPinCode();
}

public async requestPinCode(): Promise<SimklPinCodeResponse> {
Expand Down
6 changes: 5 additions & 1 deletion server/lib/integrationHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,11 @@ const checkSimkl = async (): Promise<IntegrationHealth> => {
try {
await new SimklAPI().validateClientId();
return healthy('Simkl is reachable and the Client ID was accepted.');
} catch {
} catch (e) {
logger.warn('Simkl health check failed', {
label: 'Integration Health',
errorMessage: e instanceof Error ? e.message : 'unknown error',
});
return degraded('Simkl could not accept the saved Client ID.');
}
};
Expand Down
4 changes: 3 additions & 1 deletion server/routes/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { JellyfinLibrary } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv';
import SimklAPI from '@server/api/simkl';
import SimklAPI, { SimklRequestError } from '@server/api/simkl';
import TautulliAPI from '@server/api/tautulli';
import TraktAPI from '@server/api/trakt';
import { ApiErrorCode } from '@server/constants/error';
Expand Down Expand Up @@ -749,6 +749,8 @@ settingsRoutes.post('/simkl', async (req, res, next) => {
label: 'Settings',
errorMessage: e instanceof Error ? e.message : 'unknown error',
});
if (e instanceof SimklRequestError)
return next({ status: 400, message: e.message });
return next({ status: 500, message: 'Unable to save Simkl settings.' });
}
});
Expand Down
8 changes: 8 additions & 0 deletions server/routes/user/usersettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,10 @@ userSettingsRoutes.post<{ id: string }>(
deviceCode: userCode,
});
} catch (e) {
logger.error('Failed to start Simkl PIN authorization', {
label: 'API',
errorMessage: e instanceof Error ? e.message : 'unknown error',
});
return res.status(e instanceof SimklNotConfiguredError ? 400 : 500).json({
message:
e instanceof Error
Expand Down Expand Up @@ -1223,6 +1227,10 @@ userSettingsRoutes.post<{ id: string }>(
} catch (e) {
if (e instanceof SimklAccountAlreadyLinkedError)
return res.status(409).json({ message: e.message });
logger.error('Failed to complete Simkl PIN authorization', {
label: 'API',
errorMessage: e instanceof Error ? e.message : 'unknown error',
});
return res.status(e instanceof SimklNotConfiguredError ? 400 : 500).json({
message:
e instanceof Error
Expand Down
Loading