feat: Add Parallel Library Updates Across Sources

This commit is contained in:
Rajarshee Chatterjee
2026-07-25 13:54:46 +05:30
parent 8ac628b3de
commit 799845426c
3 changed files with 219 additions and 17 deletions
@@ -53,10 +53,17 @@ object TaskNotificationFactory {
)
val icon = if (notificationIconId != 0) notificationIconId else android.R.drawable.ic_dialog_info
val progressLines = task.progressText
?.lineSequence()
?.filter { it.isNotBlank() }
?.toList()
.orEmpty()
val contentText = progressLines.firstOrNull() ?: task.description
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle(task.title)
.setContentText(task.progressText ?: task.description)
.setContentText(contentText)
.setContentIntent(contentIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
@@ -77,6 +84,19 @@ object TaskNotificationFactory {
} else {
builder.setProgress(100, (progress.coerceIn(0.0, 1.0) * 100).toInt(), false)
}
if (
task.type == "UPDATE_LIBRARY" &&
task.state in listOf(BackgroundTaskState.QUEUED, BackgroundTaskState.RUNNING, BackgroundTaskState.PAUSED) &&
progressLines.size > 1
) {
builder.setStyle(
NotificationCompat.InboxStyle().also { style ->
progressLines.forEach(style::addLine)
},
)
}
return builder.build()
}
@@ -104,4 +124,4 @@ object TaskNotificationFactory {
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
@@ -0,0 +1,136 @@
import {
getLibraryNovelsForGlobalUpdate,
getLibraryWithCategory,
} from '@database/queries/LibraryQueries';
import type { BackgroundTaskMetadata } from '@services/backgroundTasks/contracts';
import { updateNovel } from '../LibraryUpdateQueries';
import { updateLibrary } from '../index';
jest.mock('@database/queries/LibraryQueries', () => ({
getLibraryNovelsForGlobalUpdate: jest.fn(),
getLibraryWithCategory: jest.fn(),
}));
jest.mock('../LibraryUpdateQueries', () => ({
updateNovel: jest.fn(),
}));
jest.mock('@utils/sleep', () => ({
sleep: jest.fn(() => Promise.resolve()),
}));
jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
jest.mock('@utils/mmkv/mmkv', () => ({
MMKVStorage: { set: jest.fn() },
getMMKVObject: jest.fn(() => ({})),
}));
jest.mock('@hooks/persisted/useUpdates', () => ({
LAST_UPDATE_TIME: 'LAST_UPDATE_TIME',
}));
jest.mock('@hooks/persisted/useSettings', () => ({
APP_SETTINGS: 'APP_SETTINGS',
getGlobalUpdateCategoryFilters: jest.fn(() => ({
excludedCategoryIds: [],
includedCategoryIds: [],
})),
}));
const mockedGetLibraryNovels = jest.mocked(getLibraryNovelsForGlobalUpdate);
const mockedGetLibraryWithCategory = jest.mocked(getLibraryWithCategory);
const mockedUpdateNovel = jest.mocked(updateNovel);
type LibraryNovel = Awaited<
ReturnType<typeof getLibraryNovelsForGlobalUpdate>
>[number];
const novel = (id: number, pluginId: string, name: string): LibraryNovel =>
({
id,
pluginId,
name,
path: `/${name}`,
} as LibraryNovel);
const flushPromises = () => new Promise(resolve => setImmediate(resolve));
describe('updateLibrary', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('updates at most three different sources while keeping each source sequential', async () => {
const novels = [
novel(1, 'source-a', 'A One'),
novel(2, 'source-a', 'A Two'),
novel(3, 'source-b', 'B One'),
novel(4, 'source-c', 'C One'),
novel(5, 'source-d', 'D One'),
];
mockedGetLibraryNovels.mockResolvedValue(novels);
const completions = new Map<number, () => void>();
mockedUpdateNovel.mockImplementation(
(_pluginId, _path, novelId) =>
new Promise<void>(resolve => completions.set(novelId, resolve)),
);
let metadata: BackgroundTaskMetadata = {
name: 'Update library',
isRunning: false,
progress: undefined,
progressText: undefined,
};
const progressSnapshots: BackgroundTaskMetadata[] = [];
const setMeta = (
transform: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => {
metadata = transform(metadata);
progressSnapshots.push(metadata);
};
const updatePromise = updateLibrary({}, setMeta, jest.fn());
await flushPromises();
expect(mockedUpdateNovel.mock.calls.map(([pluginId]) => pluginId)).toEqual([
'source-a',
'source-b',
'source-c',
]);
expect(progressSnapshots.at(-1)?.progressText).toBe(
['A One', 'B One', 'C One'].join('\n'),
);
completions.get(3)?.();
await flushPromises();
expect(mockedUpdateNovel).toHaveBeenCalledTimes(4);
expect(mockedUpdateNovel.mock.calls[3][0]).toBe('source-d');
completions.get(1)?.();
await flushPromises();
expect(mockedUpdateNovel).toHaveBeenCalledTimes(5);
expect(mockedUpdateNovel.mock.calls[4][0]).toBe('source-a');
completions.get(2)?.();
completions.get(4)?.();
completions.get(5)?.();
await updatePromise;
expect(metadata).toMatchObject({
isRunning: false,
progress: 1,
progressText: undefined,
});
});
it('uses the category query when an update is category-scoped', async () => {
mockedGetLibraryWithCategory.mockResolvedValue([]);
await updateLibrary({ categoryId: 4 }, jest.fn(), jest.fn());
expect(mockedGetLibraryWithCategory).toHaveBeenCalledWith(
4,
undefined,
true,
);
expect(mockedGetLibraryNovels).not.toHaveBeenCalled();
});
});
+61 -15
View File
@@ -20,6 +20,23 @@ import type {
TaskProgressUpdater,
} from '@services/backgroundTasks/contracts';
const UPDATE_SOURCE_CONCURRENCY = 3;
const groupNovelsByPlugin = (novels: DBNovelInfo[]) => {
const groupedNovels = new Map<string, DBNovelInfo[]>();
for (const novel of novels) {
const pluginNovels = groupedNovels.get(novel.pluginId);
if (pluginNovels) {
pluginNovels.push(novel);
} else {
groupedNovels.set(novel.pluginId, [novel]);
}
}
return [...groupedNovels.values()];
};
const updateLibrary = async (
{
categoryId,
@@ -59,26 +76,54 @@ const updateLibrary = async (
if (libraryNovels.length > 0) {
MMKVStorage.set(LAST_UPDATE_TIME, dayjs().format('YYYY-MM-DD HH:mm:ss'));
for (let i = 0; i < libraryNovels.length; i++) {
const sourceQueues = groupNovelsByPlugin(libraryNovels);
const activeNovels = new Map<string, string>();
let completedNovels = 0;
let nextSourceQueue = 0;
const publishProgress = () => {
setMeta(meta => ({
...meta,
progressText: libraryNovels[i].name,
progress: i / libraryNovels.length,
progressText: [...activeNovels.values()].join('\n') || undefined,
progress: completedNovels / libraryNovels.length,
}));
};
try {
await updateNovel(
libraryNovels[i].pluginId,
libraryNovels[i].path,
libraryNovels[i].id,
options,
);
await sleep(1000);
} catch (error: any) {
showToast(libraryNovels[i].name + ': ' + error.message);
continue;
const updateSourceQueue = async (sourceQueue: DBNovelInfo[]) => {
for (const novel of sourceQueue) {
activeNovels.set(novel.pluginId, novel.name);
publishProgress();
try {
await updateNovel(novel.pluginId, novel.path, novel.id, options);
await sleep(1000);
} catch (error: any) {
showToast(novel.name + ': ' + error.message);
} finally {
completedNovels += 1;
activeNovels.delete(novel.pluginId);
publishProgress();
}
}
}
};
const updateNextSource = async () => {
while (nextSourceQueue < sourceQueues.length) {
const sourceQueue = sourceQueues[nextSourceQueue];
nextSourceQueue += 1;
await updateSourceQueue(sourceQueue);
}
};
await Promise.all(
Array.from(
{
length: Math.min(UPDATE_SOURCE_CONCURRENCY, sourceQueues.length),
},
updateNextSource,
),
);
} else {
showToast("There's no novel to be updated");
}
@@ -86,6 +131,7 @@ const updateLibrary = async (
setMeta(meta => ({
...meta,
progress: 1,
progressText: undefined,
isRunning: false,
}));
};