From da8ba31a88fcb2b5f0d23df557232ff7c2edb913 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 12 Jul 2025 22:21:46 -0700 Subject: [PATCH] scaffold new OS controller --- .../api/subsonic/subsonic-controller.ts | 208 ++++++++++++++++++ .../types/adapter/api-controller-types.ts | 70 +++--- 2 files changed, 243 insertions(+), 35 deletions(-) create mode 100644 src/shared/api/subsonic/subsonic-controller.ts diff --git a/src/shared/api/subsonic/subsonic-controller.ts b/src/shared/api/subsonic/subsonic-controller.ts new file mode 100644 index 000000000..4a2e74959 --- /dev/null +++ b/src/shared/api/subsonic/subsonic-controller.ts @@ -0,0 +1,208 @@ +import createClient, { Middleware } from 'openapi-fetch'; +import qs from 'qs'; + +import { paths } from './subsonic-schema'; + +import i18n from '/@/i18n/i18n'; +import { normalize } from '/@/shared/api/subsonic/subsonic-normalize'; +import { ApiController } from '/@/shared/types/adapter/api-controller-types'; +import { ApiControllerError } from '/@/shared/types/adapter/api-controller-types'; +import { ServerListItem, ServerType } from '/@/shared/types/domain/server-domain-types'; +import { LibraryItem } from '/@/shared/types/domain/shared-domain-types'; + +export function deserializeCredential(credential: string): Record { + return JSON.parse(credential); +} + +export function serializeCredential( + username: string, + credential: Record, + type: string, +) { + switch (type) { + case 'apiKey': + return JSON.stringify({ apiKey: credential.apiKey }); + case 'plaintext': + return JSON.stringify({ p: credential.password, u: username }); + case 'token': + return JSON.stringify({ s: credential.s, t: credential.t, u: username }); + default: + throw new Error(`Invalid credential type: ${type}`); + } +} + +const middleware: Middleware = { + onRequest: async () => {}, +}; + +const client = createClient({ + querySerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }), +}); + +client.use(middleware); + +type ErrorResponseArgs = { + code?: number; + message?: string; +}; + +// type Req = paths[T]['get']['parameters']; + +// type Res = T extends keyof paths +// ? paths[T]['get'] extends { +// responses: { +// '200': { content: { 'application/json': { 'subsonic-response'?: infer R } } }; +// }; +// } +// ? NonNullable +// : never +// : never; + +function errorResponse(args: ErrorResponseArgs): [ApiControllerError, null] { + const message = `${i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string}${ + args.message ? `: ${args.message}` : '' + }`; + + return [{ code: args.code || 500, message }, null]; +} + +function getSubsonicErrorMessage(subsonicErrorCode: number): string { + switch (subsonicErrorCode) { + case 0: + return 'A generic error occurred'; + case 10: + return 'Required parameter is missing'; + case 20: + return 'Incompatible Subsonic REST protocol version. Client must upgrade'; + case 30: + return 'Incompatible Subsonic REST protocol version. Server must upgrade'; + case 40: + return 'Wrong username or password'; + case 41: + return 'Token authentication not supported for LDAP users'; + case 50: + return 'User is not authorized for the given operation'; + case 60: + return 'The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium'; + case 70: + return 'The requested data was not found'; + default: + return 'An unknown error occurred'; + } +} + +function subsonicErrorResponse( + subsonicErrorCode: number, + customMessage?: string, +): [ApiControllerError, null] { + const httpStatus = toHttpErrorCode(subsonicErrorCode); + const message = customMessage || getSubsonicErrorMessage(subsonicErrorCode); + + return [{ code: httpStatus, message }, null]; +} + +/** + * Maps Subsonic error codes to appropriate HTTP status codes + * @param subsonicErrorCode - The Subsonic error code + * @returns The corresponding HTTP status code + */ +function toHttpErrorCode(subsonicErrorCode: number): number { + switch (subsonicErrorCode) { + case 0: + return 500; // Generic error - Internal Server Error + case 10: + return 400; // Required parameter is missing - Bad Request + case 20: + return 426; // Client must upgrade - Upgrade Required + case 30: + return 503; // Server must upgrade - Service Unavailable + case 40: + return 401; // Wrong username or password - Unauthorized + case 41: + return 403; // Token authentication not supported for LDAP users - Forbidden + case 50: + return 403; // User is not authorized for the given operation - Forbidden + case 60: + return 402; // Trial period over - Payment Required + case 70: + return 404; // The requested data was not found - Not Found + default: + return 500; // Unknown error - Internal Server Error + } +} + +export const adapter: ApiController = { + _utility: { + getImageUrl: ( + args: { id: string; size?: number; type: LibraryItem }, + server: ServerListItem, + ) => { + return `${server.url}/rest/getCoverArt?id=${args.id}&size=${args.size || 300}`; + }, + getStreamUrl: ( + args: { bitRate?: number; format?: string; id: string }, + server: ServerListItem, + ) => { + return `${server.url}/rest/stream?id=${args.id}&format=${args.format || 'mp3'}&maxBitRate=${args.bitRate || 320}`; + }, + }, + album: { + getDetail: async (request, server, options) => { + const { data, error } = await client.GET('/rest/getAlbum', { + baseUrl: server.url, + params: { query: { id: request.query.id } }, + ...options, + }); + + if (error) { + return errorResponse({ code: 500, message: error }); + } + + if (!data['subsonic-response']) { + return errorResponse({ code: 404, message: 'No album found' }); + } + + if (data['subsonic-response'].status !== 'ok') { + const errorCode = data['subsonic-response'].error.code; + const errorMessage = data['subsonic-response'].error.message; + return subsonicErrorResponse(errorCode, errorMessage); + } + + return [null, normalize.album(data['subsonic-response'].album, server)]; + }, + }, + albumArtist: { + // TODO: Implement album artist methods + }, + artist: { + // TODO: Implement artist methods + }, + favorite: { + // TODO: Implement favorite methods + }, + genre: { + // TODO: Implement genre methods + }, + musicFolder: { + // TODO: Implement music folder methods + }, + playlist: { + // TODO: Implement playlist methods + }, + server: { + authenticate: async ( + url: string, + body: { legacy?: boolean; password: string; username: string }, + ) => { + // TODO: Implement authentication logic + throw new Error('Authentication not implemented yet'); + }, + getType: () => ServerType.SUBSONIC, + }, + song: { + // TODO: Implement song methods + }, + user: { + // TODO: Implement user methods + }, +}; diff --git a/src/shared/types/adapter/api-controller-types.ts b/src/shared/types/adapter/api-controller-types.ts index 206f98a53..2f008efaf 100644 --- a/src/shared/types/adapter/api-controller-types.ts +++ b/src/shared/types/adapter/api-controller-types.ts @@ -102,7 +102,7 @@ export type ApiClientProps = { export type ApiController = { _utility: { - getDownloadUrl: ApiControllerFn; + getDownloadUrl?: ApiControllerFn; getImageUrl: ( args: { id: string; size?: number; type: LibraryItem }, server: ServerListItem, @@ -113,67 +113,67 @@ export type ApiController = { ) => string; }; album: { - getDetail: ApiControllerFn; + getDetail?: ApiControllerFn; getInfo?: ApiControllerFn; - getList: ApiControllerFn; - getListCount: ApiControllerFn; + getList?: ApiControllerFn; + getListCount?: ApiControllerFn; }; albumArtist: { - getDetail: ApiControllerFn; - getList: ApiControllerFn; - getListCount: ApiControllerFn; + getDetail?: ApiControllerFn; + getList?: ApiControllerFn; + getListCount?: ApiControllerFn; }; artist: { - getList: ApiControllerFn; - getListCount: ApiControllerFn; + getList?: ApiControllerFn; + getListCount?: ApiControllerFn; }; favorite: { - create: ApiControllerFn; - delete: ApiControllerFn; + create?: ApiControllerFn; + delete?: ApiControllerFn; }; genre: { - getList: ApiControllerFn; + getList?: ApiControllerFn; }; musicFolder: { - getList: ApiControllerFn; + getList?: ApiControllerFn; }; playlist: { - addTo: ApiControllerFn; - create: ApiControllerFn; - delete: ApiControllerFn; - getDetail: ApiControllerFn; - getList: ApiControllerFn; - getListCount: ApiControllerFn; - getSongList: ApiControllerFn; + addTo?: ApiControllerFn; + create?: ApiControllerFn; + delete?: ApiControllerFn; + getDetail?: ApiControllerFn; + getList?: ApiControllerFn; + getListCount?: ApiControllerFn; + getSongList?: ApiControllerFn; moveItem?: ApiControllerFn; - removeFrom: ApiControllerFn; - update: ApiControllerFn; + removeFrom?: ApiControllerFn; + update?: ApiControllerFn; }; server: { authenticate: ( url: string, body: { legacy?: boolean; password: string; username: string }, ) => Promise; - getRoles: ApiControllerFn< + getRoles?: ApiControllerFn< BaseEndpointArgs, Array >; - getServerInfo: ApiControllerFn; - getTags: ApiControllerFn; - getTranscodingUrl: ApiControllerFn; - getType: () => ServerType; - scrobble: ApiControllerFn; - search: ApiControllerFn; + getServerInfo?: ApiControllerFn; + getTags?: ApiControllerFn; + getTranscodingUrl?: ApiControllerFn; + getType?: () => ServerType; + scrobble?: ApiControllerFn; + search?: ApiControllerFn; }; song: { - getDetail: ApiControllerFn; - getList: ApiControllerFn; - getListCount: ApiControllerFn; + getDetail?: ApiControllerFn; + getList?: ApiControllerFn; + getListCount?: ApiControllerFn; getLyrics?: ApiControllerFn; - getRandomList: ApiControllerFn; - getSimilar: ApiControllerFn; + getRandomList?: ApiControllerFn; + getSimilar?: ApiControllerFn; getStructuredLyrics?: ApiControllerFn; - getTopList: ApiControllerFn; + getTopList?: ApiControllerFn; }; user: { getList?: ApiControllerFn;