feat: Add Parallel Library Updates Across Sources
This commit is contained in:
+22
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user