add CRUD routes + prometheus client
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
This commit is contained in:
parent
c0d137b4b4
commit
bb571fc349
19
README.md
19
README.md
|
@ -11,17 +11,28 @@ Backend for movie-web
|
|||
- [X] edit account name and PFP
|
||||
- [X] delete logged in user
|
||||
- [X] backend meta (name and description)
|
||||
- [ ] upsert settings
|
||||
- [ ] upsert watched items
|
||||
- [ ] upsert bookmarks
|
||||
- [X] upsert settings
|
||||
- [X] upsert progress items
|
||||
- [X] upsert bookmarks
|
||||
- [X] GET bookmarks
|
||||
- [X] GET settings
|
||||
- [X] GET progress items
|
||||
- [X] DELETE progress items
|
||||
- [ ] consume provider metrics
|
||||
- [ ] DELETE user - should delete all associated data
|
||||
- [ ] prometheus metrics
|
||||
- [ ] requests
|
||||
- [ ] user count
|
||||
- [ ] provider metrics
|
||||
- [ ] ratelimits (stored in redis)
|
||||
- [X] switch to pnpm
|
||||
- [ ] catpcha support
|
||||
- [ ] global namespacing (accounts are stored on a namespace)
|
||||
- [ ] cleanup jobs
|
||||
- [ ] cleanup expired sessions
|
||||
- [ ] cleanup old metrics
|
||||
|
||||
## Second todo list
|
||||
- [ ] think of privacy centric method of auth
|
||||
- [ ] Register
|
||||
- [ ] Login
|
||||
- [ ] global namespacing (accounts are stored on a namespace)
|
||||
|
|
|
@ -37,6 +37,7 @@
|
|||
"@mikro-orm/core": "^5.9.0",
|
||||
"@mikro-orm/postgresql": "^5.9.0",
|
||||
"fastify": "^4.21.0",
|
||||
"fastify-metrics": "^10.3.2",
|
||||
"fastify-type-provider-zod": "^1.1.9",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"neat-config": "^2.0.0",
|
||||
|
|
|
@ -17,6 +17,9 @@ dependencies:
|
|||
fastify:
|
||||
specifier: ^4.21.0
|
||||
version: 4.24.3
|
||||
fastify-metrics:
|
||||
specifier: ^10.3.2
|
||||
version: 10.3.2(fastify@4.24.3)
|
||||
fastify-type-provider-zod:
|
||||
specifier: ^1.1.9
|
||||
version: 1.1.9(fastify@4.24.3)(zod@3.22.4)
|
||||
|
@ -735,6 +738,10 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/bintrees@1.0.2:
|
||||
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
|
||||
dev: false
|
||||
|
||||
/bplist-parser@0.2.0:
|
||||
resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==}
|
||||
engines: {node: '>= 5.10.0'}
|
||||
|
@ -1226,6 +1233,16 @@ packages:
|
|||
resolution: {integrity: sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg==}
|
||||
dev: false
|
||||
|
||||
/fastify-metrics@10.3.2(fastify@4.24.3):
|
||||
resolution: {integrity: sha512-02SEIGH02zfguqRMho0LB8L7YVAj5cIgWM0iqZslIErqaUWc1iHVAOC+YXYG3S2DZU6VHdFaMyuxjEOCQHAETA==}
|
||||
peerDependencies:
|
||||
fastify: '>=4'
|
||||
dependencies:
|
||||
fastify: 4.24.3
|
||||
fastify-plugin: 4.5.1
|
||||
prom-client: 14.2.0
|
||||
dev: false
|
||||
|
||||
/fastify-plugin@4.5.1:
|
||||
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
||||
dev: false
|
||||
|
@ -2218,6 +2235,13 @@ packages:
|
|||
engines: {node: '>= 0.6.0'}
|
||||
dev: false
|
||||
|
||||
/prom-client@14.2.0:
|
||||
resolution: {integrity: sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
tdigest: 0.1.2
|
||||
dev: false
|
||||
|
||||
/proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
@ -2531,6 +2555,12 @@ packages:
|
|||
engines: {node: '>=8.0.0'}
|
||||
dev: false
|
||||
|
||||
/tdigest@0.1.2:
|
||||
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
|
||||
dependencies:
|
||||
bintrees: 1.0.2
|
||||
dev: false
|
||||
|
||||
/text-hex@1.0.0:
|
||||
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
|
||||
dev: false
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const bookmarkMetaSchema = z.object({
|
||||
title: z.string(),
|
||||
year: z.number(),
|
||||
poster: z.string().optional(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export type BookmarkMeta = z.infer<typeof bookmarkMetaSchema>;
|
||||
|
||||
@Entity({ tableName: 'bookmark' })
|
||||
@Unique({ properties: ['tmdbId', 'userId'] })
|
||||
export class Bookmark {
|
||||
@PrimaryKey({ name: 'tmdb_id' })
|
||||
tmdbId!: string;
|
||||
|
||||
@PrimaryKey({ name: 'user_id' })
|
||||
userId!: string;
|
||||
|
||||
@Property({
|
||||
name: 'meta',
|
||||
type: types.json,
|
||||
})
|
||||
meta!: BookmarkMeta;
|
||||
|
||||
@Property({ name: 'updated_at', type: 'date' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
export interface BookmarkDTO {
|
||||
tmdbId: string;
|
||||
meta: {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string;
|
||||
type: string;
|
||||
};
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function formatBookmark(bookmark: Bookmark): BookmarkDTO {
|
||||
return {
|
||||
tmdbId: bookmark.tmdbId,
|
||||
meta: {
|
||||
title: bookmark.meta.title,
|
||||
year: bookmark.meta.year,
|
||||
poster: bookmark.meta.poster,
|
||||
type: bookmark.meta.type,
|
||||
},
|
||||
updatedAt: bookmark.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const progressMetaSchema = z.object({
|
||||
title: z.string(),
|
||||
year: z.number(),
|
||||
poster: z.string().optional(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export type ProgressMeta = z.infer<typeof progressMetaSchema>;
|
||||
|
||||
@Entity({ tableName: 'progress_item' })
|
||||
@Unique({ properties: ['tmdbId', 'userId', 'seasonId', 'episodeId'] })
|
||||
export class ProgressItem {
|
||||
@PrimaryKey({ name: 'id', type: 'uuid' })
|
||||
id: string = randomUUID();
|
||||
|
||||
@Property({ name: 'tmdb_id' })
|
||||
tmdbId!: string;
|
||||
|
||||
@Property({ name: 'user_id' })
|
||||
userId!: string;
|
||||
|
||||
@Property({ name: 'season_id', nullable: true })
|
||||
seasonId?: string;
|
||||
|
||||
@Property({ name: 'episode_id', nullable: true })
|
||||
episodeId?: string;
|
||||
|
||||
@Property({
|
||||
name: 'meta',
|
||||
type: types.json,
|
||||
})
|
||||
meta!: ProgressMeta;
|
||||
|
||||
@Property({ name: 'updated_at', type: 'date' })
|
||||
updatedAt!: Date;
|
||||
|
||||
/* progress */
|
||||
@Property({ name: 'duration', type: 'bigint' })
|
||||
duration!: number;
|
||||
|
||||
@Property({ name: 'watched', type: 'bigint' })
|
||||
watched!: number;
|
||||
}
|
||||
|
||||
export interface ProgressItemDTO {
|
||||
tmdbId: string;
|
||||
seasonId?: string;
|
||||
episodeId?: string;
|
||||
meta: {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string;
|
||||
type: string;
|
||||
};
|
||||
duration: number;
|
||||
watched: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function formatProgressItem(
|
||||
progressItem: ProgressItem,
|
||||
): ProgressItemDTO {
|
||||
return {
|
||||
tmdbId: progressItem.tmdbId,
|
||||
seasonId: progressItem.seasonId,
|
||||
episodeId: progressItem.episodeId,
|
||||
meta: {
|
||||
title: progressItem.meta.title,
|
||||
year: progressItem.meta.year,
|
||||
poster: progressItem.meta.poster,
|
||||
type: progressItem.meta.type,
|
||||
},
|
||||
duration: progressItem.duration,
|
||||
watched: progressItem.watched,
|
||||
updatedAt: progressItem.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@Entity({ tableName: 'user_settings' })
|
||||
export class UserSettings {
|
||||
@PrimaryKey({ name: 'id', type: 'uuid' })
|
||||
id: string = randomUUID();
|
||||
|
||||
@Property({ name: 'application_theme', nullable: true })
|
||||
applicationTheme?: string | null;
|
||||
|
||||
@Property({ name: 'application_language', nullable: true })
|
||||
applicationLanguage?: string | null;
|
||||
|
||||
@Property({ name: 'default_subtitle_language', nullable: true })
|
||||
defaultSubtitleLanguage?: string | null;
|
||||
}
|
||||
|
||||
export interface UserSettingsDTO {
|
||||
id: string;
|
||||
applicationTheme?: string | null;
|
||||
applicationLanguage?: string | null;
|
||||
defaultSubtitleLanguage?: string | null;
|
||||
}
|
||||
|
||||
export function formatUserSettings(
|
||||
userSettings: UserSettings,
|
||||
): UserSettingsDTO {
|
||||
return {
|
||||
id: userSettings.id,
|
||||
applicationTheme: userSettings.applicationTheme,
|
||||
applicationLanguage: userSettings.applicationLanguage,
|
||||
defaultSubtitleLanguage: userSettings.defaultSubtitleLanguage,
|
||||
};
|
||||
}
|
|
@ -2,17 +2,26 @@ import { loginAuthRouter } from '@/routes/auth/login';
|
|||
import { manageAuthRouter } from '@/routes/auth/manage';
|
||||
import { metaRouter } from '@/routes/meta';
|
||||
import { sessionsRouter } from '@/routes/sessions';
|
||||
import { userBookmarkRouter } from '@/routes/users/bookmark';
|
||||
import { userDeleteRouter } from '@/routes/users/delete';
|
||||
import { userEditRouter } from '@/routes/users/edit';
|
||||
import { userProgressRouter } from '@/routes/users/progress';
|
||||
import { userSessionsRouter } from '@/routes/users/sessions';
|
||||
import { userSettingsRouter } from '@/routes/users/settings';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import metricsPlugin from 'fastify-metrics';
|
||||
|
||||
export async function setupRoutes(app: FastifyInstance) {
|
||||
app.register(manageAuthRouter.register);
|
||||
app.register(loginAuthRouter.register);
|
||||
app.register(userSessionsRouter.register);
|
||||
app.register(sessionsRouter.register);
|
||||
app.register(userEditRouter.register);
|
||||
app.register(userDeleteRouter.register);
|
||||
app.register(metaRouter.register);
|
||||
await app.register(metricsPlugin, { endpoint: '/metrics' });
|
||||
|
||||
await app.register(manageAuthRouter.register);
|
||||
await app.register(loginAuthRouter.register);
|
||||
await app.register(userSessionsRouter.register);
|
||||
await app.register(sessionsRouter.register);
|
||||
await app.register(userEditRouter.register);
|
||||
await app.register(userDeleteRouter.register);
|
||||
await app.register(metaRouter.register);
|
||||
await app.register(userProgressRouter.register);
|
||||
await app.register(userBookmarkRouter.register);
|
||||
await app.register(userSettingsRouter.register);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
Bookmark,
|
||||
bookmarkMetaSchema,
|
||||
formatBookmark,
|
||||
} from '@/db/models/Bookmark';
|
||||
import { StatusError } from '@/services/error';
|
||||
import { handle } from '@/services/handler';
|
||||
import { makeRouter } from '@/services/router';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const userBookmarkRouter = makeRouter((app) => {
|
||||
app.get(
|
||||
'/users/:uid/bookmarks',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handle(async ({ auth, params, em }) => {
|
||||
await auth.assert();
|
||||
|
||||
if (auth.user.id !== params.uid)
|
||||
throw new StatusError('Cannot access other user information', 403);
|
||||
|
||||
const bookmarks = await em.find(Bookmark, {
|
||||
userId: params.uid,
|
||||
});
|
||||
|
||||
return bookmarks.map(formatBookmark);
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/:uid/bookmarks/:tmdbid',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
uid: z.string(),
|
||||
tmdbid: z.string(),
|
||||
}),
|
||||
body: z.object({
|
||||
meta: bookmarkMetaSchema,
|
||||
}),
|
||||
},
|
||||
},
|
||||
handle(async ({ auth, params, body, em }) => {
|
||||
await auth.assert();
|
||||
|
||||
if (auth.user.id !== params.uid)
|
||||
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||
|
||||
const oldBookmark = await em.findOne(Bookmark, {
|
||||
userId: params.uid,
|
||||
tmdbId: params.tmdbid,
|
||||
});
|
||||
if (oldBookmark) throw new StatusError('Already bookmarked', 400);
|
||||
|
||||
const bookmark = new Bookmark();
|
||||
em.assign(bookmark, {
|
||||
userId: params.uid,
|
||||
tmdbId: params.tmdbid,
|
||||
meta: body.meta,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await em.persistAndFlush(bookmark);
|
||||
return formatBookmark(bookmark);
|
||||
}),
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/:uid/bookmarks/:tmdbid',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
uid: z.string(),
|
||||
tmdbid: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handle(async ({ auth, params, em }) => {
|
||||
await auth.assert();
|
||||
|
||||
if (auth.user.id !== params.uid)
|
||||
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||
|
||||
const bookmark = await em.findOne(Bookmark, {
|
||||
userId: params.uid,
|
||||
tmdbId: params.tmdbid,
|
||||
});
|
||||
|
||||
if (!bookmark) return { tmdbId: params.tmdbid };
|
||||
|
||||
await em.removeAndFlush(bookmark);
|
||||
return { tmdbId: params.tmdbid };
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -0,0 +1,126 @@
|
|||
import {
|
||||
ProgressItem,
|
||||
formatProgressItem,
|
||||
progressMetaSchema,
|
||||
} from '@/db/models/ProgressItem';
|
||||
import { StatusError } from '@/services/error';
|
||||
import { handle } from '@/services/handler';
|
||||
import { makeRouter } from '@/services/router';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const userProgressRouter = makeRouter((app) => {
|
||||
app.put(
|
||||
'/users/:uid/progress/:tmdbid',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
uid: z.string(),
|
||||
tmdbid: z.string(),
|
||||
}),
|
||||
body: z.object({
|
||||
meta: progressMetaSchema,
|
||||
seasonId: z.string().optional(),
|
||||
episodeId: z.string().optional(),
|
||||
duration: z.number(),
|
||||
watched: z.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handle(async ({ auth, params, body, em }) => {
|
||||
await auth.assert();
|
||||
|
||||
if (auth.user.id !== params.uid)
|
||||
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||
|
||||
let progressItem = await em.findOne(ProgressItem, {
|
||||
userId: params.uid,
|
||||
tmdbId: params.tmdbid,
|
||||
episodeId: body.episodeId,
|
||||
seasonId: body.seasonId,
|
||||
});
|
||||
if (!progressItem) {
|
||||
progressItem = new ProgressItem();
|
||||
progressItem.tmdbId = params.tmdbid;
|
||||
progressItem.userId = params.uid;
|
||||
progressItem.episodeId = body.episodeId;
|
||||
progressItem.seasonId = body.seasonId;
|
||||
}
|
||||
|
||||
em.assign(progressItem, {
|
||||
duration: body.duration,
|
||||
watched: body.watched,
|
||||
meta: body.meta,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await em.persistAndFlush(progressItem);
|
||||
return formatProgressItem(progressItem);
|
||||
}),
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/:uid/progress/:tmdbid',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
uid: z.string(),
|
||||
tmdbid: z.string(),
|
||||
}),
|
||||
body: z.object({
|
||||
seasonId: z.string().optional(),
|
||||
episodeId: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handle(async ({ auth, params, body, em }) => {
|
||||
await auth.assert();
|
||||
|
||||
if (auth.user.id !== params.uid)
|
||||
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||
|
||||
const progressItem = await em.findOne(ProgressItem, {
|
||||
userId: params.uid,
|
||||
tmdbId: params.tmdbid,
|
||||
episodeId: body.episodeId,
|
||||
seasonId: body.seasonId,
|
||||
});
|
||||
if (!progressItem) {
|
||||
return {
|
||||
tmdbId: params.tmdbid,
|
||||
episodeId: body.episodeId,
|
||||
seasonId: body.seasonId,
|
||||
};
|
||||
}
|
||||
|
||||
await em.removeAndFlush(progressItem);
|
||||
return {
|
||||
tmdbId: params.tmdbid,
|
||||
episodeId: body.episodeId,
|
||||
seasonId: body.seasonId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/:uid/progress',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handle(async ({ auth, params, em }) => {
|
||||
await auth.assert();
|
||||
|
||||
if (auth.user.id !== params.uid)
|
||||
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||
|
||||
const items = await em.find(ProgressItem, {
|
||||
userId: params.uid,
|
||||
});
|
||||
|
||||
return items.map(formatProgressItem);
|
||||
}),
|
||||
);
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
import { UserSettings, formatUserSettings } from '@/db/models/UserSettings';
|
||||
import { StatusError } from '@/services/error';
|
||||
import { handle } from '@/services/handler';
|
||||
import { makeRouter } from '@/services/router';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const userSettingsRouter = makeRouter((app) => {
|
||||
app.get(
|
||||
'/users/:uid/settings',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handle(async ({ auth, params, em }) => {
|
||||
await auth.assert();
|
||||
|
||||
if (auth.user.id !== params.uid)
|
||||
throw new StatusError('Cannot get other user information', 403);
|
||||
|
||||
const settings = await em.findOne(UserSettings, {
|
||||
id: params.uid,
|
||||
});
|
||||
|
||||
if (!settings) return { id: params.uid };
|
||||
|
||||
return formatUserSettings(settings);
|
||||
}),
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/:uid/settings',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
uid: z.string(),
|
||||
}),
|
||||
body: z.object({
|
||||
applicationLanguage: z.string().optional(),
|
||||
applicationTheme: z.string().optional(),
|
||||
defaultSubtitleLanguage: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handle(async ({ auth, params, body, em }) => {
|
||||
await auth.assert();
|
||||
|
||||
if (auth.user.id !== params.uid)
|
||||
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||
|
||||
let settings = await em.findOne(UserSettings, {
|
||||
id: params.uid,
|
||||
});
|
||||
if (!settings) {
|
||||
settings = new UserSettings();
|
||||
settings.id = params.uid;
|
||||
}
|
||||
|
||||
if (body.applicationLanguage)
|
||||
settings.applicationLanguage = body.applicationLanguage;
|
||||
if (body.applicationTheme)
|
||||
settings.applicationTheme = body.applicationTheme;
|
||||
if (body.defaultSubtitleLanguage)
|
||||
settings.defaultSubtitleLanguage = body.defaultSubtitleLanguage;
|
||||
|
||||
await em.persistAndFlush(settings);
|
||||
return formatUserSettings(settings);
|
||||
}),
|
||||
);
|
||||
});
|
Loading…
Reference in New Issue