2024-10-05 23:17:20 +02:00
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
2024-10-06 01:27:51 +02:00
import { requestManager } from '@/lib/requests/RequestManager.ts' ;
2025-08-15 22:02:58 +02:00
import { CategoryIdInfo } from '@/features/category/Category.types.ts' ;
2024-10-05 23:17:20 +02:00
import {
AppMetadataKeys ,
GqlMetaHolder ,
2025-01-05 02:15:03 +01:00
MetadataHolder ,
2024-10-26 17:25:04 +02:00
MetadataHolderType ,
2024-10-05 23:17:20 +02:00
MetadataKeyValuePair ,
2025-08-15 22:02:58 +02:00
} from '@/features/metadata/Metadata.types.ts' ;
import { MangaIdInfo } from '@/features/manga/Manga.types.ts' ;
import { getMetadataKey } from '@/features/metadata/Metadata.utils.ts' ;
import { convertToGqlMeta } from '@/features/metadata/services/MetadataConverter.ts' ;
2026-01-27 23:19:37 +01:00
import { MetadataChunker } from '@/features/metadata/services/MetadataChunker.ts' ;
2025-08-15 22:02:58 +02:00
import { SourceIdInfo } from '@/features/source/Source.types.ts' ;
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts' ;
2026-01-27 23:19:37 +01:00
import { MetaInput } from '@/lib/graphql/generated/graphql.ts' ;
2024-10-05 23:17:20 +02:00
2026-01-25 20:32:56 +01:00
type MetadataUpdateOptions = {
update? : MetadataKeyValuePair [];
delete ?: AppMetadataKeys [];
/**
* Only ever pass the "migration" key value pair into this.
*/
migrate? : MetadataKeyValuePair [];
keyPrefixes? : string [];
/**
* Applies to "update" and "delete" metadata value pairs. "migrate" is excluded from this and is always expected to not be already converted to a metadata key
*/
isMetadataKey? : boolean ;
};
2026-03-06 21:40:41 +01:00
type ProcessedEntityMetadata = {
updateMetas : MetaInput [];
deleteKeys : string [];
migrateMetas : MetaInput [];
};
const processEntityMetadata = (
2024-10-05 23:17:20 +02:00
metadataHolder : GqlMetaHolder ,
holderType : MetadataHolderType ,
2026-01-25 20:32:56 +01:00
{
update : keysToValues = [],
delete : keysToDelete = [],
migrate : keysToMigrate = [],
keyPrefixes ,
isMetadataKey = false ,
} : MetadataUpdateOptions ,
2026-03-06 21:40:41 +01:00
) : ProcessedEntityMetadata => {
2026-01-25 20:32:56 +01:00
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"` ,
);
}
2026-01-27 23:19:37 +01:00
const existingMetadata = MetadataChunker . getExistingMetadata ( metadataHolder , holderType );
const allUpdateMetas : MetaInput [] = [];
const allDeleteKeys : string [] = [];
keysToValues . forEach (([ key , value ]) => {
const fullKey = isMetadataKey ? key : getMetadataKey ( key , keyPrefixes );
const stringValue = ` ${ value } ` ;
const chunkEntries = MetadataChunker . chunkValue ( fullKey , stringValue );
allUpdateMetas . push (... chunkEntries );
const doFullCleanup = !! existingMetadata ;
if ( doFullCleanup ) {
const newChunkCount = chunkEntries . length - 1 ;
allDeleteKeys . push (... MetadataChunker . computeChunkDeletions ( existingMetadata , fullKey , newChunkCount ));
} else {
const isNowChunked = chunkEntries . length > 1 ;
if ( ! isNowChunked ) {
allDeleteKeys . push ( MetadataChunker . getChunkLengthKey ( fullKey ));
}
}
});
keysToDelete . forEach (( key ) => {
const fullKey = isMetadataKey ? key : getMetadataKey ( key , keyPrefixes );
allDeleteKeys . push ( fullKey );
allDeleteKeys . push (... MetadataChunker . computeChunkDeletions ( existingMetadata , fullKey , 0 ));
});
const uniqueDeleteKeys = [... new Set ( allDeleteKeys )];
2026-01-25 20:32:56 +01:00
const migrateMetas = keysToMigrate . map (([ key , value ]) => ({
key : getMetadataKey ( key , keyPrefixes ),
value : ` ${ value } ` ,
}));
2026-03-06 21:40:41 +01:00
return { updateMetas : allUpdateMetas , deleteKeys : uniqueDeleteKeys , migrateMetas };
};
type ProcessedEntry = ProcessedEntityMetadata & { metadataHolder : GqlMetaHolder };
const groupByIdenticalMetas = < Id extends number | string >(
processed : Array < ProcessedEntry & { metadataHolder : { id : Id } }>,
) : {
updateGroups : Array < { ids : Id []; metas : MetaInput [] } > ;
deleteGroups : Array < { ids : Id []; keys : string [] } > ;
migrateGroups : Array < { ids : Id []; metas : MetaInput [] } > ;
} => {
const updateMap = new Map < string , { ids : Id []; metas : MetaInput [] }>();
const deleteMap = new Map < string , { ids : Id []; keys : string [] }>();
const migrateMap = new Map < string , { ids : Id []; metas : MetaInput [] }>();
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 = < Key extends string , Id extends number | string >(
processed : ProcessedEntry [],
idKey : Key ,
) => {
const { updateGroups , deleteGroups , migrateGroups } = groupByIdenticalMetas (
processed as Array < ProcessedEntry & { metadataHolder : { id : Id } }>,
);
return {
updateInput : {
items : updateGroups.map (
({ ids , metas }) => ({ [ idKey ] : ids , metas }) as Record < Key , Id [] > & { metas : MetaInput [] },
),
},
deleteInput : {
items : deleteGroups.map (
({ ids , keys }) => ({ [ idKey ] : ids , keys }) as Record < Key , Id [] > & { keys : string [] },
),
},
migrateInput : {
items : migrateGroups.map (
({ ids , metas }) => ({ [ idKey ] : ids , metas }) as Record < Key , Id [] > & { metas : MetaInput [] },
),
},
};
};
const requestBatchMetadataUpdate = async (
holderType : MetadataHolderType ,
entries : Array < { metadataHolders : GqlMetaHolder []; options : MetadataUpdateOptions } > ,
) : Promise < void > => {
if ( entries . length === 0 ) return ;
const processed = entries . flatMap (({ metadataHolders , options }) =>
metadataHolders . map (( metadataHolder ) => ({
metadataHolder ,
... processEntityMetadata ( metadataHolder , holderType , options ),
})),
);
2026-01-24 21:04:06 +01:00
switch ( holderType ) {
2026-03-06 21:40:41 +01:00
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 );
2026-02-02 23:53:59 +01:00
await requestManager . updateGlobalMeta ({
2026-03-06 21:40:41 +01:00
updateInput : { metas : withUpdates.flatMap (({ updateMetas }) => updateMetas ) },
deleteInput : { keys : withDeletes.flatMap (({ deleteKeys }) => deleteKeys ) },
migrateInput : { metas : withMigrations.flatMap (({ migrateMetas }) => migrateMetas ) },
2026-02-02 23:53:59 +01:00
}). response ;
2026-01-24 21:04:06 +01:00
break ;
2026-02-02 23:53:59 +01:00
}
2026-03-06 21:40:41 +01:00
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 ;
2026-01-24 21:04:06 +01:00
break ;
default :
2026-03-06 21:40:41 +01:00
throw new Error ( `requestBatchMetadataUpdate: unknown holderType " ${ holderType } "` );
2026-01-24 21:04:06 +01:00
}
};
2024-10-05 23:17:20 +02:00
2026-03-06 21:40:41 +01:00
export const requestBatchServerMetadataUpdate = async (
entries : Array < { options : MetadataUpdateOptions } > ,
) : Promise < void > =>
requestBatchMetadataUpdate (
'global' ,
entries . map (({ options }) => ({ metadataHolders : [{}], options })),
);
export const requestBatchMangaMetadataUpdate = async (
entries : Array < { mangas : ( MangaIdInfo & GqlMetaHolder )[]; options : MetadataUpdateOptions } > ,
) : Promise < void > =>
requestBatchMetadataUpdate (
'manga' ,
entries . map (({ mangas , options }) => ({ metadataHolders : mangas , options })),
);
export const requestBatchChapterMetadataUpdate = async (
entries : Array < { chapters : ( ChapterIdInfo & GqlMetaHolder )[]; options : MetadataUpdateOptions } > ,
) : Promise < void > =>
requestBatchMetadataUpdate (
'chapter' ,
entries . map (({ chapters , options }) => ({ metadataHolders : chapters , options })),
);
export const requestBatchCategoryMetadataUpdate = async (
entries : Array < { categories : ( CategoryIdInfo & GqlMetaHolder )[]; options : MetadataUpdateOptions } > ,
) : Promise < void > =>
requestBatchMetadataUpdate (
'category' ,
entries . map (({ categories , options }) => ({ metadataHolders : categories , options })),
);
export const requestBatchSourceMetadataUpdate = async (
entries : Array < { sources : ( SourceIdInfo & GqlMetaHolder )[]; options : MetadataUpdateOptions } > ,
) : Promise < void > =>
requestBatchMetadataUpdate (
'source' ,
entries . map (({ sources , options }) => ({ metadataHolders : sources , options })),
);
2026-01-25 20:32:56 +01:00
export const requestServerMetadataUpdate = async ( options : MetadataUpdateOptions ) : Promise < void > =>
2026-03-06 21:40:41 +01:00
requestBatchServerMetadataUpdate ([{ options }]);
2024-10-05 23:17:20 +02:00
2026-01-25 20:32:56 +01:00
export const requestMangaMetadataUpdate = async (
2024-10-05 23:17:20 +02:00
manga : MangaIdInfo & GqlMetaHolder ,
2026-01-25 20:32:56 +01:00
options : MetadataUpdateOptions ,
2026-03-06 21:40:41 +01:00
) : Promise < void > => requestBatchMangaMetadataUpdate ([{ mangas : [ manga ], options }]);
2024-10-05 23:17:20 +02:00
2026-01-25 20:32:56 +01:00
export const requestChapterMetadataUpdate = async (
2024-10-05 23:17:20 +02:00
chapter : ChapterIdInfo & GqlMetaHolder ,
2026-01-25 20:32:56 +01:00
options : MetadataUpdateOptions ,
2026-03-06 21:40:41 +01:00
) : Promise < void > => requestBatchChapterMetadataUpdate ([{ chapters : [ chapter ], options }]);
2024-10-05 23:17:20 +02:00
2026-01-25 20:32:56 +01:00
export const requestCategoryMetadataUpdate = async (
2024-10-05 23:17:20 +02:00
category : CategoryIdInfo & GqlMetaHolder ,
2026-01-25 20:32:56 +01:00
options : MetadataUpdateOptions ,
2026-03-06 21:40:41 +01:00
) : Promise < void > => requestBatchCategoryMetadataUpdate ([{ categories : [ category ], options }]);
2024-10-05 23:17:20 +02:00
2026-01-25 20:32:56 +01:00
export const requestSourceMetadataUpdate = async (
2025-04-21 17:00:13 +02:00
source : SourceIdInfo & GqlMetaHolder ,
2026-01-25 20:32:56 +01:00
options : MetadataUpdateOptions ,
2026-03-06 21:40:41 +01:00
) : Promise < void > => requestBatchSourceMetadataUpdate ([{ sources : [ source ], options }]);
2025-01-05 02:15:03 +01:00
export const getMetadataUpdateFunction = (
type : MetadataHolderType ,
metadataHolder :
| MetadataHolder
| ( MangaIdInfo & MetadataHolder )
| ( ChapterIdInfo & MetadataHolder )
| ( CategoryIdInfo & MetadataHolder )
2025-04-21 17:00:13 +02:00
| ( SourceIdInfo & MetadataHolder ),
2026-01-25 20:32:56 +01:00
) : (( options : MetadataUpdateOptions ) => Promise < void >) => {
2025-01-05 02:15:03 +01:00
switch ( type ) {
case 'global' :
2026-01-25 20:32:56 +01:00
return ( options ) => requestServerMetadataUpdate ( options );
2025-01-05 02:15:03 +01:00
case 'manga' :
2026-01-25 20:32:56 +01:00
return ( options ) =>
requestMangaMetadataUpdate (
2025-01-05 02:15:03 +01:00
{ id : ( metadataHolder as MangaIdInfo ). id , meta : convertToGqlMeta ( metadataHolder . meta ) },
2026-01-25 20:32:56 +01:00
options ,
2025-01-05 02:15:03 +01:00
);
case 'chapter' :
2026-01-25 20:32:56 +01:00
return ( options ) =>
requestChapterMetadataUpdate (
2025-01-05 02:15:03 +01:00
{ id : ( metadataHolder as ChapterIdInfo ). id , meta : convertToGqlMeta ( metadataHolder . meta ) },
2026-01-25 20:32:56 +01:00
options ,
2025-01-05 02:15:03 +01:00
);
case 'category' :
2026-01-25 20:32:56 +01:00
return ( options ) =>
requestCategoryMetadataUpdate (
2025-01-05 02:15:03 +01:00
{ id : ( metadataHolder as CategoryIdInfo ). id , meta : convertToGqlMeta ( metadataHolder . meta ) },
2026-01-25 20:32:56 +01:00
options ,
2025-01-05 02:15:03 +01:00
);
case 'source' :
2026-01-25 20:32:56 +01:00
return ( options ) =>
requestSourceMetadataUpdate (
{ id : ( metadataHolder as SourceIdInfo ). id , meta : convertToGqlMeta ( metadataHolder . meta ) },
options ,
2025-01-05 02:15:03 +01:00
);
default :
throw new Error ( `Unexpected "type" ( ${ type } )` );
}
};