Fix duplicate VOE and Vidmoly recovery claims
Share account-scoped recovery claims with login uploader instances and serialize same-title recovery windows. Reserve direct and recovered file codes consistently, reject reused identities, and cover concurrent singleton recovery plus distinct-code and parallel-success cases.
This commit is contained in:
@@ -5,11 +5,14 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const hosters = require('../lib/hosters');
|
||||
const VoeUploader = require('../lib/voe-upload');
|
||||
const VidmolyUploader = require('../lib/vidmoly-upload');
|
||||
const originalUploadFile = hosters.uploadFile;
|
||||
const originalPrefetchBaseline = hosters.prefetchBaseline;
|
||||
let tempRoot;
|
||||
let firstPath;
|
||||
let secondPath;
|
||||
let distinctPath;
|
||||
let UploadManager;
|
||||
|
||||
before(() => {
|
||||
@@ -20,8 +23,10 @@ before(() => {
|
||||
fs.mkdirSync(secondDir);
|
||||
firstPath = path.join(firstDir, 'Shared Episode.mkv');
|
||||
secondPath = path.join(secondDir, 'shared-episode.mp4');
|
||||
distinctPath = path.join(secondDir, 'different-title.mkv');
|
||||
fs.writeFileSync(firstPath, Buffer.alloc(1024, 1));
|
||||
fs.writeFileSync(secondPath, Buffer.alloc(1024, 2));
|
||||
fs.writeFileSync(distinctPath, Buffer.alloc(1024, 3));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
@@ -31,16 +36,16 @@ after(() => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function loadManager(uploadFile) {
|
||||
function loadManager(uploadFile = originalUploadFile) {
|
||||
hosters.uploadFile = uploadFile;
|
||||
hosters.prefetchBaseline = async () => new Set();
|
||||
delete require.cache[require.resolve('../lib/upload-manager')];
|
||||
UploadManager = require('../lib/upload-manager');
|
||||
}
|
||||
|
||||
function settings(parallelCount) {
|
||||
function settings(hoster, parallelCount) {
|
||||
return {
|
||||
'byse.sx': {
|
||||
[hoster]: {
|
||||
retries: 0,
|
||||
parallelCount,
|
||||
maxSpeedKbs: 0,
|
||||
@@ -60,6 +65,29 @@ async function runBatch(manager, tasks) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function withUploaderMethods(Uploader, upload, operation) {
|
||||
const originalLogin = Uploader.prototype.login;
|
||||
const originalUpload = Uploader.prototype.upload;
|
||||
Uploader.prototype.login = async function () {};
|
||||
Uploader.prototype.upload = upload;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
Uploader.prototype.login = originalLogin;
|
||||
Uploader.prototype.upload = originalUpload;
|
||||
}
|
||||
}
|
||||
|
||||
function waitFor(promise, timeoutMs, message) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
})
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
test('a batch shares recovery claims across normalized same-name jobs', async () => {
|
||||
let unsafeCalls = 0;
|
||||
loadManager(async (hoster, file, apiKey, onProgress, signal, throttle, options) => {
|
||||
@@ -81,7 +109,7 @@ test('a batch shares recovery claims across normalized same-name jobs', async ()
|
||||
error.hosterTransient = true;
|
||||
throw error;
|
||||
});
|
||||
const manager = new UploadManager(settings(2));
|
||||
const manager = new UploadManager(settings('byse.sx', 2));
|
||||
|
||||
const summary = await runBatch(manager, [
|
||||
{ jobId: 'same-name-a', file: firstPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' },
|
||||
@@ -107,7 +135,7 @@ test('recovery claims stay isolated between accounts', async () => {
|
||||
download_url: 'https://byse.sx/d/SHARED_REMOTE_CODE'
|
||||
};
|
||||
});
|
||||
const manager = new UploadManager(settings(2));
|
||||
const manager = new UploadManager(settings('byse.sx', 2));
|
||||
|
||||
const summary = await runBatch(manager, [
|
||||
{ jobId: 'account-a', file: firstPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_A' },
|
||||
@@ -133,7 +161,7 @@ test('normalized same-name recovery sections never overlap', async () => {
|
||||
download_url: `https://byse.sx/d/SERIAL_${sequence}`
|
||||
};
|
||||
});
|
||||
const manager = new UploadManager(settings(2));
|
||||
const manager = new UploadManager(settings('byse.sx', 2));
|
||||
|
||||
const summary = await runBatch(manager, [
|
||||
{ jobId: 'serialized-a', file: firstPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' },
|
||||
@@ -143,3 +171,147 @@ test('normalized same-name recovery sections never overlap', async () => {
|
||||
assert.equal(summary.succeeded, 2);
|
||||
assert.equal(maximumActive, 1);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
label: 'VOE',
|
||||
hoster: 'voe.sx',
|
||||
Uploader: VoeUploader,
|
||||
sharedCode: 'SHAREDVOE01',
|
||||
distinctCodes: ['VOEDISTINCT1', 'VOEDISTINCT2'],
|
||||
buildResult(uploader, code) {
|
||||
return uploader._buildUrls(code);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Vidmoly',
|
||||
hoster: 'vidmoly.me',
|
||||
Uploader: VidmolyUploader,
|
||||
sharedCode: 'SHAREDVID001',
|
||||
distinctCodes: ['VIDDISTINCT1', 'VIDDISTINCT2'],
|
||||
buildResult(uploader, code) {
|
||||
return uploader._buildUrlsFromCode(code);
|
||||
}
|
||||
}
|
||||
]) {
|
||||
test(`${scenario.label} uploader instances reject a duplicate direct remote code for same-name sources`, async () => {
|
||||
await withUploaderMethods(
|
||||
scenario.Uploader,
|
||||
async function () {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return scenario.buildResult(this, scenario.sharedCode);
|
||||
},
|
||||
async () => {
|
||||
loadManager();
|
||||
const manager = new UploadManager(settings(scenario.hoster, 2));
|
||||
const summary = await runBatch(manager, [
|
||||
{
|
||||
jobId: `${scenario.label}-same-name-a`,
|
||||
file: firstPath,
|
||||
hoster: scenario.hoster,
|
||||
accountId: 'LOGIN_ACCOUNT',
|
||||
username: 'account@example.test',
|
||||
password: 'password'
|
||||
},
|
||||
{
|
||||
jobId: `${scenario.label}-same-name-b`,
|
||||
file: secondPath,
|
||||
hoster: scenario.hoster,
|
||||
accountId: 'LOGIN_ACCOUNT',
|
||||
username: 'account@example.test',
|
||||
password: 'password'
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(summary.succeeded, 1);
|
||||
assert.equal(summary.failed, 1);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test(`${scenario.label} uploader instances isolate direct remote code claims between accounts`, async () => {
|
||||
await withUploaderMethods(
|
||||
scenario.Uploader,
|
||||
async function () {
|
||||
return scenario.buildResult(this, scenario.sharedCode);
|
||||
},
|
||||
async () => {
|
||||
loadManager();
|
||||
const manager = new UploadManager(settings(scenario.hoster, 2));
|
||||
const summary = await runBatch(manager, [
|
||||
{
|
||||
jobId: `${scenario.label}-account-a`,
|
||||
file: firstPath,
|
||||
hoster: scenario.hoster,
|
||||
accountId: 'LOGIN_ACCOUNT_A',
|
||||
username: 'account-a@example.test',
|
||||
password: 'password'
|
||||
},
|
||||
{
|
||||
jobId: `${scenario.label}-account-b`,
|
||||
file: secondPath,
|
||||
hoster: scenario.hoster,
|
||||
accountId: 'LOGIN_ACCOUNT_B',
|
||||
username: 'account-b@example.test',
|
||||
password: 'password'
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(summary.succeeded, 2);
|
||||
assert.equal(summary.failed, 0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test(`${scenario.label} uploader instances preserve parallel success for distinct remote identities`, async () => {
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
let started = 0;
|
||||
let releaseBoth;
|
||||
const bothStarted = new Promise(resolve => {
|
||||
releaseBoth = resolve;
|
||||
});
|
||||
await withUploaderMethods(
|
||||
scenario.Uploader,
|
||||
async function (filePath) {
|
||||
active++;
|
||||
started++;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
if (started === 2) releaseBoth();
|
||||
try {
|
||||
await waitFor(bothStarted, 500, 'Distinct uploads did not overlap');
|
||||
const code = filePath === firstPath ? scenario.distinctCodes[0] : scenario.distinctCodes[1];
|
||||
return scenario.buildResult(this, code);
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
loadManager();
|
||||
const manager = new UploadManager(settings(scenario.hoster, 2));
|
||||
const summary = await runBatch(manager, [
|
||||
{
|
||||
jobId: `${scenario.label}-distinct-a`,
|
||||
file: firstPath,
|
||||
hoster: scenario.hoster,
|
||||
accountId: 'LOGIN_ACCOUNT',
|
||||
username: 'account@example.test',
|
||||
password: 'password'
|
||||
},
|
||||
{
|
||||
jobId: `${scenario.label}-distinct-b`,
|
||||
file: distinctPath,
|
||||
hoster: scenario.hoster,
|
||||
accountId: 'LOGIN_ACCOUNT',
|
||||
username: 'account@example.test',
|
||||
password: 'password'
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(summary.succeeded, 2);
|
||||
assert.equal(summary.failed, 0);
|
||||
assert.equal(maximumActive, 2);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user