Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion src/lib/util/sync/core/providers/mega-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,61 @@ vi.mock('megajs', () => {
return { Storage: MockStorage, File: MockFile };
});

import { megaCore, isImageUpload } from './mega-core';
import { megaCore, isImageUpload, resolveSeriesUploadFolder } from './mega-core';

describe('resolveSeriesUploadFolder (no duplicate series folders)', () => {
function makeStorage(opts: { files?: any; rootChildren?: any[]; reloadAdds?: any } = {}) {
const storage: any = {
files: { ...(opts.files ?? {}) },
root: {
children: opts.rootChildren ?? [],
mkdir: vi.fn(async (name: string) => ({
name,
directory: true,
children: [],
mkdir: vi.fn(async (n: string) => ({ name: n, directory: true, children: [] }))
}))
},
reload: vi.fn(async () => {
Object.assign(storage.files, opts.reloadAdds ?? {});
})
};
return storage;
}

it('returns the folder by id without reload or mkdir when already in the tree', async () => {
const folder = { nodeId: 'F1', name: 'S', directory: true };
const storage = makeStorage({ files: { F1: folder } });
const result = await resolveSeriesUploadFolder(storage, 'F1', 'S');
expect(result).toBe(folder);
expect(storage.reload).not.toHaveBeenCalled();
expect(storage.root.mkdir).not.toHaveBeenCalled();
});

it('reloads once to find a folder created after the cached tree, never mkdir', async () => {
const folder = { nodeId: 'F1', name: 'S', directory: true };
const storage = makeStorage({ files: {}, reloadAdds: { F1: folder } });
const result = await resolveSeriesUploadFolder(storage, 'F1', 'S');
expect(storage.reload).toHaveBeenCalledWith(true);
expect(result).toBe(folder);
expect(storage.root.mkdir).not.toHaveBeenCalled();
});

it('falls back to finding an existing folder by name when no id is provided', async () => {
const seriesFolder = { name: 'S', directory: true };
const mokuro = { name: 'mokuro-reader', directory: true, children: [seriesFolder] };
const storage = makeStorage({ rootChildren: [mokuro] });
const result = await resolveSeriesUploadFolder(storage, undefined, 'S');
expect(result).toBe(seriesFolder);
expect(storage.root.mkdir).not.toHaveBeenCalled();
});

it('only creates folders by name in the legacy no-id path', async () => {
const storage = makeStorage({ rootChildren: [] });
await resolveSeriesUploadFolder(storage, undefined, 'New Series');
expect(storage.root.mkdir).toHaveBeenCalledWith('mokuro-reader');
});
});

describe('isImageUpload (MEGA thumbnail eligibility)', () => {
it('detects images by mime type', () => {
Expand Down
65 changes: 51 additions & 14 deletions src/lib/util/sync/core/providers/mega-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,47 @@ function getDownloadApi(sid: string): any {
return api;
}

/**
* Resolve the series folder to upload into.
*
* The main thread's prepareUploadTarget() creates the folder once (coalesced by a mutex)
* and passes its node id here. Parallel upload workers must NEVER mkdir the series folder:
* each worker has its own Storage tree, so concurrent mkdir creates duplicate series
* folders. We resolve the folder by id, reloading once if our cached tree predates its
* creation, and only fall back to name-based creation when no id was supplied.
*/
export async function resolveSeriesUploadFolder(
storage: any,
seriesFolderNodeId: string | undefined,
seriesTitle: string
): Promise<any> {
if (seriesFolderNodeId) {
let folder = storage.files?.[seriesFolderNodeId];
if (!folder) {
await storage.reload(true);
folder = storage.files?.[seriesFolderNodeId];
}
if (folder) return folder;
// Id was provided but didn't resolve even after a reload — fall through rather than
// failing the upload outright.
}

// Legacy fallback (only when no id was provided): find by name, create if missing.
let mokuroFolder = storage.root?.children?.find(
(child: any) => child.name === 'mokuro-reader' && child.directory
);
if (!mokuroFolder) {
mokuroFolder = await storage.root.mkdir('mokuro-reader');
}
let seriesFolder = mokuroFolder.children?.find(
(child: any) => child.name === seriesTitle && child.directory
);
if (!seriesFolder) {
seriesFolder = await mokuroFolder.mkdir(seriesTitle);
}
return seriesFolder;
}

const IMAGE_MIME_RE = /^image\//;
const IMAGE_EXT_RE = /\.(webp|jpe?g|png|gif|bmp|avif)$/i;

Expand Down Expand Up @@ -222,22 +263,18 @@ export const megaCore: CloudProviderCore = {
try {
const CHUNK_SIZE = 1024 * 1024;

let mokuroFolder = storage.root.children?.find(
(child: any) => child.name === 'mokuro-reader' && child.directory
// Upload into the folder the main thread already created (passed as a node id).
// Never mkdir here — parallel workers would each create a duplicate series folder.
const seriesFolderNodeId =
typeof credentials?.megaSeriesFolderNodeId === 'string'
? credentials.megaSeriesFolderNodeId
: undefined;
const seriesFolder = await resolveSeriesUploadFolder(
storage,
seriesFolderNodeId,
seriesTitle
);

if (!mokuroFolder) {
mokuroFolder = await storage.root.mkdir('mokuro-reader');
}

let seriesFolder = mokuroFolder.children?.find(
(child: any) => child.name === seriesTitle && child.directory
);

if (!seriesFolder) {
seriesFolder = await mokuroFolder.mkdir(seriesTitle);
}

const uploadStream: any = seriesFolder.upload({ name: filename, size: blob.size });
let uploadedFileId: string | undefined;
let uploadedFile: any;
Expand Down
14 changes: 14 additions & 0 deletions src/lib/util/sync/providers/mega/mega-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,17 @@ describe('MegaProvider.reinitialize() — session-safe refresh', () => {
expect(localStorage.getItem('mega_session')).toBeNull();
});
});

describe('MegaProvider.prepareUploadTarget()', () => {
it('returns the series folder node id so workers reuse it instead of mkdir-ing', async () => {
const provider = new MegaProvider();
await provider.whenReady();
(provider as any).storage = { files: {} };
(provider as any).ensureMokuroFolder = vi.fn(async () => ({ nodeId: 'MOKURO' }));
(provider as any).ensureSeriesFolder = vi.fn(async () => ({ nodeId: 'SERIES1' }));

const result = await provider.prepareUploadTarget('My Series');

expect(result).toEqual({ megaSeriesFolderNodeId: 'SERIES1' });
});
});
9 changes: 7 additions & 2 deletions src/lib/util/sync/providers/mega/mega-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,9 +1146,14 @@ export class MegaProvider implements SyncProvider {
return { megaSession: session };
}

async prepareUploadTarget(seriesTitle: string): Promise<void> {
async prepareUploadTarget(seriesTitle: string): Promise<Record<string, any>> {
// Create the series folder once here (coalesced by ensureSeriesFolder's mutex) and pass
// its node id to the upload workers. Workers must NOT mkdir — each worker has its own
// Storage tree, so parallel mkdir would create duplicate series folders.
const mokuroFolder = await this.ensureMokuroFolder();
await this.ensureSeriesFolder(seriesTitle, mokuroFolder);
const seriesFolder = await this.ensureSeriesFolder(seriesTitle, mokuroFolder);
const nodeId = seriesFolder?.nodeId ?? seriesFolder?.id;
return nodeId ? { megaSeriesFolderNodeId: nodeId } : {};
}

async getWorkerDownloadCredentials(fileId: string): Promise<Record<string, any>> {
Expand Down
Loading