From 57e05bf20d4c3e0ac166eecd6318cdaec971c55f Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:40:41 +0100 Subject: [PATCH] Introduce new batch metadata update function --- .../category/services/CategoryMetadata.ts | 26 ++- src/features/manga/services/MangaMetadata.ts | 26 ++- .../metadata/services/MetadataUpdater.ts | 221 ++++++++++++++---- .../components/SourceLanguageSelect.tsx | 21 +- .../source/services/SourceMetadata.ts | 26 ++- 5 files changed, 266 insertions(+), 54 deletions(-) diff --git a/src/features/category/services/CategoryMetadata.ts b/src/features/category/services/CategoryMetadata.ts index e9396450..9f8aec9e 100644 --- a/src/features/category/services/CategoryMetadata.ts +++ b/src/features/category/services/CategoryMetadata.ts @@ -7,7 +7,10 @@ */ import { useEffect, useMemo } from 'react'; -import { requestCategoryMetadataUpdate } from '@/features/metadata/services/MetadataUpdater.ts'; +import { + requestCategoryMetadataUpdate, + requestBatchCategoryMetadataUpdate, +} from '@/features/metadata/services/MetadataUpdater.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { LibraryOptions } from '@/features/library/Library.types.ts'; import { CategoryIdInfo, CategoryMetadataKeys, ICategoryMetadata } from '@/features/category/Category.types.ts'; @@ -85,6 +88,27 @@ export const updateCategoryMetadata = async < update: [[metadataKey, convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey]]], }); +export const batchUpdateCategoryMetadata = async < + MetadataKeys extends CategoryMetadataKeys = CategoryMetadataKeys, + MetadataKey extends MetadataKeys = MetadataKeys, +>( + updates: Array<{ + categories: (CategoryIdInfo & GqlMetaHolder)[]; + entries: Array<{ metadataKey: MetadataKey; value: ICategoryMetadata[MetadataKey] }>; + }>, +): Promise => + requestBatchCategoryMetadataUpdate( + updates.map(({ categories, entries }) => ({ + categories, + options: { + update: entries.map(({ metadataKey, value }) => [ + metadataKey, + convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey], + ]), + }, + })), + ); + export const createUpdateCategoryMetadata = ( category: CategoryIdInfo & GqlMetaHolder, diff --git a/src/features/manga/services/MangaMetadata.ts b/src/features/manga/services/MangaMetadata.ts index 0dca9e67..bab695f9 100644 --- a/src/features/manga/services/MangaMetadata.ts +++ b/src/features/manga/services/MangaMetadata.ts @@ -17,7 +17,10 @@ import { MetadataHolder, } from '@/features/metadata/Metadata.types.ts'; import { convertFromGqlMeta } from '@/features/metadata/services/MetadataConverter.ts'; -import { requestMangaMetadataUpdate } from '@/features/metadata/services/MetadataUpdater.ts'; +import { + requestMangaMetadataUpdate, + requestBatchMangaMetadataUpdate, +} from '@/features/metadata/services/MetadataUpdater.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; const DEFAULT_MANGA_METADATA: MangaMetadata = { @@ -73,6 +76,27 @@ export const updateMangaMetadata = async < update: [[metadataKey, convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey]]], }); +export const batchUpdateMangaMetadata = async < + MetadataKeys extends MangaMetadataKeys = MangaMetadataKeys, + MetadataKey extends MetadataKeys = MetadataKeys, +>( + updates: Array<{ + mangas: (MangaIdInfo & GqlMetaHolder)[]; + entries: Array<{ metadataKey: MetadataKey; value: MangaMetadata[MetadataKey] }>; + }>, +): Promise => + requestBatchMangaMetadataUpdate( + updates.map(({ mangas, entries }) => ({ + mangas, + options: { + update: entries.map(({ metadataKey, value }) => [ + metadataKey, + convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey], + ]), + }, + })), + ); + export const createUpdateMangaMetadata = ( manga: MangaIdInfo & GqlMetaHolder, diff --git a/src/features/metadata/services/MetadataUpdater.ts b/src/features/metadata/services/MetadataUpdater.ts index 18a48574..8ec0528c 100644 --- a/src/features/metadata/services/MetadataUpdater.ts +++ b/src/features/metadata/services/MetadataUpdater.ts @@ -37,7 +37,13 @@ type MetadataUpdateOptions = { isMetadataKey?: boolean; }; -const requestMetadataUpdate = async ( +type ProcessedEntityMetadata = { + updateMetas: MetaInput[]; + deleteKeys: string[]; + migrateMetas: MetaInput[]; +}; + +const processEntityMetadata = ( metadataHolder: GqlMetaHolder, holderType: MetadataHolderType, { @@ -47,7 +53,7 @@ const requestMetadataUpdate = async ( keyPrefixes, isMetadataKey = false, }: MetadataUpdateOptions, -): Promise => { +): ProcessedEntityMetadata => { if (keysToMigrate?.length > 1 || (keysToMigrate?.length === 1 && keysToMigrate[0][0] !== 'migration')) { throw new Error( `requestMetadataUpdate: "migrate" option must only contain a single key-value pair with the key "migration"`, @@ -92,77 +98,200 @@ const requestMetadataUpdate = async ( value: `${value}`, })); + return { updateMetas: allUpdateMetas, deleteKeys: uniqueDeleteKeys, migrateMetas }; +}; + +type ProcessedEntry = ProcessedEntityMetadata & { metadataHolder: GqlMetaHolder }; + +const groupByIdenticalMetas = ( + processed: Array, +): { + updateGroups: Array<{ ids: Id[]; metas: MetaInput[] }>; + deleteGroups: Array<{ ids: Id[]; keys: string[] }>; + migrateGroups: Array<{ ids: Id[]; metas: MetaInput[] }>; +} => { + const updateMap = new Map(); + const deleteMap = new Map(); + const migrateMap = new Map(); + + for (const entry of processed) { + const { id } = entry.metadataHolder; + + if (entry.updateMetas.length > 0) { + const key = JSON.stringify(entry.updateMetas); + const existing = updateMap.get(key); + if (existing) { + existing.ids.push(id); + } else { + updateMap.set(key, { ids: [id], metas: entry.updateMetas }); + } + } + + if (entry.deleteKeys.length > 0) { + const key = JSON.stringify(entry.deleteKeys); + const existing = deleteMap.get(key); + if (existing) { + existing.ids.push(id); + } else { + deleteMap.set(key, { ids: [id], keys: entry.deleteKeys }); + } + } + + if (entry.migrateMetas.length > 0) { + const key = JSON.stringify(entry.migrateMetas); + const existing = migrateMap.get(key); + if (existing) { + existing.ids.push(id); + } else { + migrateMap.set(key, { ids: [id], metas: entry.migrateMetas }); + } + } + } + + return { + updateGroups: [...updateMap.values()], + deleteGroups: [...deleteMap.values()], + migrateGroups: [...migrateMap.values()], + }; +}; + +const createEntityMetaInput = ( + processed: ProcessedEntry[], + idKey: Key, +) => { + const { updateGroups, deleteGroups, migrateGroups } = groupByIdenticalMetas( + processed as Array, + ); + + return { + updateInput: { + items: updateGroups.map( + ({ ids, metas }) => ({ [idKey]: ids, metas }) as Record & { metas: MetaInput[] }, + ), + }, + deleteInput: { + items: deleteGroups.map( + ({ ids, keys }) => ({ [idKey]: ids, keys }) as Record & { keys: string[] }, + ), + }, + migrateInput: { + items: migrateGroups.map( + ({ ids, metas }) => ({ [idKey]: ids, metas }) as Record & { metas: MetaInput[] }, + ), + }, + }; +}; + +const requestBatchMetadataUpdate = async ( + holderType: MetadataHolderType, + entries: Array<{ metadataHolders: GqlMetaHolder[]; options: MetadataUpdateOptions }>, +): Promise => { + if (entries.length === 0) return; + + const processed = entries.flatMap(({ metadataHolders, options }) => + metadataHolders.map((metadataHolder) => ({ + metadataHolder, + ...processEntityMetadata(metadataHolder, holderType, options), + })), + ); + switch (holderType) { - case 'category': { - const categoryId = (metadataHolder as CategoryIdInfo).id; - await requestManager.updateCategoryMeta({ - updateInput: { items: [{ categoryIds: [categoryId], metas: allUpdateMetas }] }, - deleteInput: { items: [{ categoryIds: [categoryId], keys: uniqueDeleteKeys }] }, - migrateInput: { items: [{ categoryIds: [categoryId], metas: migrateMetas }] }, - }).response; - break; - } - case 'chapter': { - const chapterId = (metadataHolder as ChapterIdInfo).id; - await requestManager.updateChapterMeta({ - updateInput: { items: [{ chapterIds: [chapterId], metas: allUpdateMetas }] }, - deleteInput: { items: [{ chapterIds: [chapterId], keys: uniqueDeleteKeys }] }, - migrateInput: { items: [{ chapterIds: [chapterId], metas: migrateMetas }] }, - }).response; - break; - } - case 'global': + case 'global': { + const withUpdates = processed.filter(({ updateMetas }) => updateMetas.length > 0); + const withDeletes = processed.filter(({ deleteKeys }) => deleteKeys.length > 0); + const withMigrations = processed.filter(({ migrateMetas }) => migrateMetas.length > 0); + await requestManager.updateGlobalMeta({ - updateInput: { metas: allUpdateMetas }, - deleteInput: { keys: uniqueDeleteKeys }, - migrateInput: { metas: migrateMetas }, - }).response; - break; - case 'manga': { - const mangaId = (metadataHolder as MangaIdInfo).id; - await requestManager.updateMangaMeta({ - updateInput: { items: [{ mangaIds: [mangaId], metas: allUpdateMetas }] }, - deleteInput: { items: [{ mangaIds: [mangaId], keys: uniqueDeleteKeys }] }, - migrateInput: { items: [{ mangaIds: [mangaId], metas: migrateMetas }] }, + updateInput: { metas: withUpdates.flatMap(({ updateMetas }) => updateMetas) }, + deleteInput: { keys: withDeletes.flatMap(({ deleteKeys }) => deleteKeys) }, + migrateInput: { metas: withMigrations.flatMap(({ migrateMetas }) => migrateMetas) }, }).response; break; } - case 'source': { - const sourceId = (metadataHolder as SourceIdInfo).id; - await requestManager.updateSourceMeta({ - updateInput: { items: [{ sourceIds: [sourceId], metas: allUpdateMetas }] }, - deleteInput: { items: [{ sourceIds: [sourceId], keys: uniqueDeleteKeys }] }, - migrateInput: { items: [{ sourceIds: [sourceId], metas: migrateMetas }] }, - }).response; + case 'category': + await requestManager.updateCategoryMeta( + createEntityMetaInput<'categoryIds', number>(processed, 'categoryIds'), + ).response; + break; + case 'chapter': + await requestManager.updateChapterMeta(createEntityMetaInput<'chapterIds', number>(processed, 'chapterIds')) + .response; + break; + case 'manga': + await requestManager.updateMangaMeta(createEntityMetaInput<'mangaIds', number>(processed, 'mangaIds')) + .response; + break; + case 'source': + await requestManager.updateSourceMeta(createEntityMetaInput<'sourceIds', string>(processed, 'sourceIds')) + .response; break; - } default: - throw new Error(`requestMetadataUpdate: unknown holderType "${holderType}"`); + throw new Error(`requestBatchMetadataUpdate: unknown holderType "${holderType}"`); } }; +export const requestBatchServerMetadataUpdate = async ( + entries: Array<{ options: MetadataUpdateOptions }>, +): Promise => + requestBatchMetadataUpdate( + 'global', + entries.map(({ options }) => ({ metadataHolders: [{}], options })), + ); + +export const requestBatchMangaMetadataUpdate = async ( + entries: Array<{ mangas: (MangaIdInfo & GqlMetaHolder)[]; options: MetadataUpdateOptions }>, +): Promise => + requestBatchMetadataUpdate( + 'manga', + entries.map(({ mangas, options }) => ({ metadataHolders: mangas, options })), + ); + +export const requestBatchChapterMetadataUpdate = async ( + entries: Array<{ chapters: (ChapterIdInfo & GqlMetaHolder)[]; options: MetadataUpdateOptions }>, +): Promise => + requestBatchMetadataUpdate( + 'chapter', + entries.map(({ chapters, options }) => ({ metadataHolders: chapters, options })), + ); + +export const requestBatchCategoryMetadataUpdate = async ( + entries: Array<{ categories: (CategoryIdInfo & GqlMetaHolder)[]; options: MetadataUpdateOptions }>, +): Promise => + requestBatchMetadataUpdate( + 'category', + entries.map(({ categories, options }) => ({ metadataHolders: categories, options })), + ); + +export const requestBatchSourceMetadataUpdate = async ( + entries: Array<{ sources: (SourceIdInfo & GqlMetaHolder)[]; options: MetadataUpdateOptions }>, +): Promise => + requestBatchMetadataUpdate( + 'source', + entries.map(({ sources, options }) => ({ metadataHolders: sources, options })), + ); + export const requestServerMetadataUpdate = async (options: MetadataUpdateOptions): Promise => - requestMetadataUpdate({}, 'global', options); + requestBatchServerMetadataUpdate([{ options }]); export const requestMangaMetadataUpdate = async ( manga: MangaIdInfo & GqlMetaHolder, options: MetadataUpdateOptions, -): Promise => requestMetadataUpdate(manga, 'manga', options); +): Promise => requestBatchMangaMetadataUpdate([{ mangas: [manga], options }]); export const requestChapterMetadataUpdate = async ( chapter: ChapterIdInfo & GqlMetaHolder, options: MetadataUpdateOptions, -): Promise => requestMetadataUpdate(chapter, 'chapter', options); +): Promise => requestBatchChapterMetadataUpdate([{ chapters: [chapter], options }]); export const requestCategoryMetadataUpdate = async ( category: CategoryIdInfo & GqlMetaHolder, options: MetadataUpdateOptions, -): Promise => requestMetadataUpdate(category, 'category', options); +): Promise => requestBatchCategoryMetadataUpdate([{ categories: [category], options }]); export const requestSourceMetadataUpdate = async ( source: SourceIdInfo & GqlMetaHolder, options: MetadataUpdateOptions, -): Promise => requestMetadataUpdate(source, 'source', options); +): Promise => requestBatchSourceMetadataUpdate([{ sources: [source], options }]); export const getMetadataUpdateFunction = ( type: MetadataHolderType, diff --git a/src/features/source/components/SourceLanguageSelect.tsx b/src/features/source/components/SourceLanguageSelect.tsx index 1e767a02..37ce8fdd 100644 --- a/src/features/source/components/SourceLanguageSelect.tsx +++ b/src/features/source/components/SourceLanguageSelect.tsx @@ -34,7 +34,7 @@ import { SourceNameInfo, } from '@/features/source/Source.types.ts'; import { Sources } from '@/features/source/services/Sources'; -import { getSourceMetadata, updateSourceMetadata } from '@/features/source/services/SourceMetadata.ts'; +import { batchUpdateSourceMetadata, getSourceMetadata } from '@/features/source/services/SourceMetadata.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx'; import { makeToast } from '@/base/utils/Toast.ts'; @@ -113,10 +113,21 @@ export const SourceLanguageSelect = ({ const handleOk = () => { setOpen(false); - Promise.all( - Object.entries(tmpSourceIdToEnabledState).map(([sourceId, enabled]) => - updateSourceMetadata(sources.find((source) => source.id === sourceId)!, 'isEnabled', enabled), - ), + batchUpdateSourceMetadata( + Object.entries(tmpSourceIdToEnabledState) + .map(([sourceId, enabled]) => { + const source = sources.find((sourceToEnable) => sourceToEnable.id === sourceId); + + if (!source) { + return null; + } + + return { + sources: [source], + entries: [{ metadataKey: 'isEnabled' as const, value: enabled }], + }; + }) + .filter((entry) => entry !== null), ).catch((e) => makeToast(t`Failed to save changes`, 'error', getErrorMessage(e))); setTmpSourceIdToEnabledState({}); setSelectedLanguages(toUniqueLanguageCodes(tmpSelectedLanguages)); diff --git a/src/features/source/services/SourceMetadata.ts b/src/features/source/services/SourceMetadata.ts index f1d83550..fd7683ce 100644 --- a/src/features/source/services/SourceMetadata.ts +++ b/src/features/source/services/SourceMetadata.ts @@ -7,7 +7,10 @@ */ import { useEffect, useMemo } from 'react'; -import { requestSourceMetadataUpdate } from '@/features/metadata/services/MetadataUpdater.ts'; +import { + requestSourceMetadataUpdate, + requestBatchSourceMetadataUpdate, +} from '@/features/metadata/services/MetadataUpdater.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { ISourceMetadata, SourceIdInfo, SourceMetadataKeys } from '@/features/source/Source.types.ts'; import { convertFromGqlMeta } from '@/features/metadata/services/MetadataConverter.ts'; @@ -55,6 +58,27 @@ export const updateSourceMetadata = async < update: [[metadataKey, convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey]]], }); +export const batchUpdateSourceMetadata = async < + MetadataKeys extends SourceMetadataKeys = SourceMetadataKeys, + MetadataKey extends MetadataKeys = MetadataKeys, +>( + updates: Array<{ + sources: (SourceIdInfo & GqlMetaHolder)[]; + entries: Array<{ metadataKey: MetadataKey; value: ISourceMetadata[MetadataKey] }>; + }>, +): Promise => + requestBatchSourceMetadataUpdate( + updates.map(({ sources, entries }) => ({ + sources, + options: { + update: entries.map(({ metadataKey, value }) => [ + metadataKey, + convertAppMetadataToGqlMetadata({ [metadataKey]: value })[metadataKey], + ]), + }, + })), + ); + export const createUpdateSourceMetadata = ( source: SourceIdInfo & GqlMetaHolder,