scaffold new OS controller

This commit is contained in:
jeffvli
2025-07-12 22:21:46 -07:00
parent d8a8880e48
commit da8ba31a88
2 changed files with 243 additions and 35 deletions
@@ -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<string, string> {
return JSON.parse(credential);
}
export function serializeCredential(
username: string,
credential: Record<string, string>,
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<paths>({
querySerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }),
});
client.use(middleware);
type ErrorResponseArgs = {
code?: number;
message?: string;
};
// type Req<T extends keyof paths> = paths[T]['get']['parameters'];
// type Res<T extends keyof paths> = T extends keyof paths
// ? paths[T]['get'] extends {
// responses: {
// '200': { content: { 'application/json': { 'subsonic-response'?: infer R } } };
// };
// }
// ? NonNullable<R>
// : 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
},
};
@@ -102,7 +102,7 @@ export type ApiClientProps = {
export type ApiController = {
_utility: {
getDownloadUrl: ApiControllerFn<DownloadRequest, string>;
getDownloadUrl?: ApiControllerFn<DownloadRequest, string>;
getImageUrl: (
args: { id: string; size?: number; type: LibraryItem },
server: ServerListItem,
@@ -113,67 +113,67 @@ export type ApiController = {
) => string;
};
album: {
getDetail: ApiControllerFn<AlbumDetailRequest, AlbumDetailResponse>;
getDetail?: ApiControllerFn<AlbumDetailRequest, AlbumDetailResponse>;
getInfo?: ApiControllerFn<AlbumDetailRequest, AlbumInfo>;
getList: ApiControllerFn<AlbumListRequest, AlbumListResponse>;
getListCount: ApiControllerFn<AlbumListRequest, number>;
getList?: ApiControllerFn<AlbumListRequest, AlbumListResponse>;
getListCount?: ApiControllerFn<AlbumListRequest, number>;
};
albumArtist: {
getDetail: ApiControllerFn<AlbumArtistDetailRequest, AlbumArtistDetailResponse>;
getList: ApiControllerFn<AlbumArtistListRequest, AlbumArtistListResponse>;
getListCount: ApiControllerFn<AlbumArtistListRequest, number>;
getDetail?: ApiControllerFn<AlbumArtistDetailRequest, AlbumArtistDetailResponse>;
getList?: ApiControllerFn<AlbumArtistListRequest, AlbumArtistListResponse>;
getListCount?: ApiControllerFn<AlbumArtistListRequest, number>;
};
artist: {
getList: ApiControllerFn<ArtistListRequest, ArtistListResponse>;
getListCount: ApiControllerFn<ArtistListRequest, number>;
getList?: ApiControllerFn<ArtistListRequest, ArtistListResponse>;
getListCount?: ApiControllerFn<ArtistListRequest, number>;
};
favorite: {
create: ApiControllerFn<FavoriteRequest, FavoriteResponse>;
delete: ApiControllerFn<FavoriteRequest, FavoriteResponse>;
create?: ApiControllerFn<FavoriteRequest, FavoriteResponse>;
delete?: ApiControllerFn<FavoriteRequest, FavoriteResponse>;
};
genre: {
getList: ApiControllerFn<GenreListRequest, GenreListResponse>;
getList?: ApiControllerFn<GenreListRequest, GenreListResponse>;
};
musicFolder: {
getList: ApiControllerFn<ServerMusicFolderListRequest, ServerMusicFolderListResponse>;
getList?: ApiControllerFn<ServerMusicFolderListRequest, ServerMusicFolderListResponse>;
};
playlist: {
addTo: ApiControllerFn<AddToPlaylistArgs, AddToPlaylistResponse>;
create: ApiControllerFn<CreatePlaylistRequest, CreatePlaylistResponse>;
delete: ApiControllerFn<DeletePlaylistRequest, DeletePlaylistResponse>;
getDetail: ApiControllerFn<PlaylistDetailRequest, PlaylistDetailResponse>;
getList: ApiControllerFn<PlaylistListRequest, PlaylistListResponse>;
getListCount: ApiControllerFn<PlaylistListRequest, number>;
getSongList: ApiControllerFn<PlaylistSongListRequest, SongListResponse>;
addTo?: ApiControllerFn<AddToPlaylistArgs, AddToPlaylistResponse>;
create?: ApiControllerFn<CreatePlaylistRequest, CreatePlaylistResponse>;
delete?: ApiControllerFn<DeletePlaylistRequest, DeletePlaylistResponse>;
getDetail?: ApiControllerFn<PlaylistDetailRequest, PlaylistDetailResponse>;
getList?: ApiControllerFn<PlaylistListRequest, PlaylistListResponse>;
getListCount?: ApiControllerFn<PlaylistListRequest, number>;
getSongList?: ApiControllerFn<PlaylistSongListRequest, SongListResponse>;
moveItem?: ApiControllerFn<MoveItemRequest, void>;
removeFrom: ApiControllerFn<RemoveFromPlaylistRequest, RemoveFromPlaylistResponse>;
update: ApiControllerFn<UpdatePlaylistRequest, UpdatePlaylistResponse>;
removeFrom?: ApiControllerFn<RemoveFromPlaylistRequest, RemoveFromPlaylistResponse>;
update?: ApiControllerFn<UpdatePlaylistRequest, UpdatePlaylistResponse>;
};
server: {
authenticate: (
url: string,
body: { legacy?: boolean; password: string; username: string },
) => Promise<AuthenticationResponse>;
getRoles: ApiControllerFn<
getRoles?: ApiControllerFn<
BaseEndpointArgs,
Array<string | { label: string; value: string }>
>;
getServerInfo: ApiControllerFn<ServerInfoRequest, ServerInfo>;
getTags: ApiControllerFn<TagRequest, TagsResponse>;
getTranscodingUrl: ApiControllerFn<TranscodingRequest, string>;
getType: () => ServerType;
scrobble: ApiControllerFn<ScrobbleRequest, ScrobbleResponse>;
search: ApiControllerFn<SearchRequest, SearchResponse>;
getServerInfo?: ApiControllerFn<ServerInfoRequest, ServerInfo>;
getTags?: ApiControllerFn<TagRequest, TagsResponse>;
getTranscodingUrl?: ApiControllerFn<TranscodingRequest, string>;
getType?: () => ServerType;
scrobble?: ApiControllerFn<ScrobbleRequest, ScrobbleResponse>;
search?: ApiControllerFn<SearchRequest, SearchResponse>;
};
song: {
getDetail: ApiControllerFn<SongDetailRequest, SongDetailResponse>;
getList: ApiControllerFn<SongListRequest, SongListResponse>;
getListCount: ApiControllerFn<SongListRequest, number>;
getDetail?: ApiControllerFn<SongDetailRequest, SongDetailResponse>;
getList?: ApiControllerFn<SongListRequest, SongListResponse>;
getListCount?: ApiControllerFn<SongListRequest, number>;
getLyrics?: ApiControllerFn<LyricsRequest, LyricsResponse>;
getRandomList: ApiControllerFn<RandomSongListRequest, SongListResponse>;
getSimilar: ApiControllerFn<SimilarSongsRequest, Song[]>;
getRandomList?: ApiControllerFn<RandomSongListRequest, SongListResponse>;
getSimilar?: ApiControllerFn<SimilarSongsRequest, Song[]>;
getStructuredLyrics?: ApiControllerFn<StructuredLyricsRequest, StructuredLyric[]>;
getTopList: ApiControllerFn<TopSongListRequest, TopSongListResponse>;
getTopList?: ApiControllerFn<TopSongListRequest, TopSongListResponse>;
};
user: {
getList?: ApiControllerFn<UserListRequest, UserListResponse>;