release: v2.0.6
Redesign the desktop workspace with task sidebars, live filters, clearer settings, and an accessible update dialog. Harden encrypted backup imports, configuration persistence, history retention, queue snapshots, shutdown recovery, and update installation ordering. Publish verified Windows artifacts and refreshed English documentation.
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { partitionRestoredJobsByLog, completedSelectionKeys } = require('../lib/queue-dedup');
|
||||
|
||||
function job(status, fileName, hoster) {
|
||||
return { status, fileName, hoster, file: `C:/dl/${fileName}` };
|
||||
}
|
||||
|
||||
test('regression: pending preview jobs are NEVER dropped, even when all match the log', () => {
|
||||
// Exact shape of the reproduced bug: 4 preview jobs for one file across 4
|
||||
// hosters, every fileName|hoster present in the lifetime upload log.
|
||||
const jobs = [
|
||||
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'doodstream.com'),
|
||||
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'voe.sx'),
|
||||
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'vidmoly.me'),
|
||||
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'byse.sx')
|
||||
];
|
||||
const log = [
|
||||
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'doodstream.com' },
|
||||
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'voe.sx' },
|
||||
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'vidmoly.me' },
|
||||
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'byse.sx' }
|
||||
];
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||
assert.equal(removed.length, 0, 'no pending job may be removed');
|
||||
assert.equal(kept.length, 4, 'all 4 pending jobs survive restart/update');
|
||||
});
|
||||
|
||||
test('done jobs in the log are dropped (declutter); pending/error/aborted kept', () => {
|
||||
const jobs = [
|
||||
job('done', 'a.mkv', 'doodstream.com'),
|
||||
job('preview', 'a.mkv', 'voe.sx'),
|
||||
job('error', 'b.mkv', 'doodstream.com'),
|
||||
job('aborted', 'c.mkv', 'doodstream.com')
|
||||
];
|
||||
const log = [
|
||||
{ fileName: 'a.mkv', hoster: 'doodstream.com' },
|
||||
{ fileName: 'a.mkv', hoster: 'voe.sx' },
|
||||
{ fileName: 'b.mkv', hoster: 'doodstream.com' },
|
||||
{ fileName: 'c.mkv', hoster: 'doodstream.com' }
|
||||
];
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||
assert.equal(removed.length, 1);
|
||||
assert.equal(removed[0].status, 'done');
|
||||
assert.equal(removed[0].hoster, 'doodstream.com');
|
||||
// The preview a.mkv|voe.sx, error b.mkv, aborted c.mkv all survive.
|
||||
assert.equal(kept.length, 3);
|
||||
assert.ok(kept.some(j => j.status === 'preview' && j.hoster === 'voe.sx'));
|
||||
assert.ok(kept.some(j => j.status === 'error'));
|
||||
assert.ok(kept.some(j => j.status === 'aborted'));
|
||||
});
|
||||
|
||||
test('done job NOT in the log is kept (e.g. hoster had logToFile disabled)', () => {
|
||||
const jobs = [job('done', 'd.mkv', 'doodstream.com')];
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, []);
|
||||
assert.equal(removed.length, 0);
|
||||
assert.equal(kept.length, 1);
|
||||
});
|
||||
|
||||
test('case-insensitive match on fileName and hoster', () => {
|
||||
const jobs = [job('done', 'Movie.MKV', 'DoodStream.com')];
|
||||
const log = [{ fileName: 'movie.mkv', hoster: 'doodstream.com' }];
|
||||
const { removed } = partitionRestoredJobsByLog(jobs, log);
|
||||
assert.equal(removed.length, 1);
|
||||
});
|
||||
|
||||
test('empty/missing inputs do not throw', () => {
|
||||
assert.deepEqual(partitionRestoredJobsByLog([], []), { kept: [], removed: [] });
|
||||
assert.deepEqual(partitionRestoredJobsByLog(null, null), { kept: [], removed: [] });
|
||||
const jobs = [job('done', 'x.mkv', 'voe.sx')];
|
||||
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
|
||||
});
|
||||
|
||||
const T = (s) => Date.parse(s.replace(' ', 'T'));
|
||||
|
||||
test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => {
|
||||
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 1, 'completed-after-snapshot preview is a ghost → drop');
|
||||
assert.equal(kept.length, 0);
|
||||
});
|
||||
|
||||
test('ts-gate: preview job whose only log entry PREDATES the snapshot is kept (intentional re-upload)', () => {
|
||||
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 0, 'old upload + freshly-queued re-upload must survive');
|
||||
assert.equal(kept.length, 1);
|
||||
});
|
||||
|
||||
test('ts-gate: same-second completion is dropped (savedAt floored to the second)', () => {
|
||||
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:00') }];
|
||||
const savedAt = T('2026-06-19 12:00:00') + 800;
|
||||
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 1, 'log second-granularity must not let same-second ghosts slip through');
|
||||
});
|
||||
|
||||
test('ts-gate: uses the MAX log ts per key (re-upload after a stale earlier entry)', () => {
|
||||
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||
const log = [
|
||||
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') },
|
||||
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }
|
||||
];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 1, 'newest matching log entry decides');
|
||||
});
|
||||
|
||||
test('ts-gate inactive without savedAt → legacy behavior (preview kept even if ts newer)', () => {
|
||||
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||
assert.equal(removed.length, 0);
|
||||
assert.equal(kept.length, 1);
|
||||
});
|
||||
|
||||
test('ts-gate inactive when log entry lacks ts → legacy behavior', () => {
|
||||
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx' }];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 0);
|
||||
assert.equal(kept.length, 1);
|
||||
});
|
||||
|
||||
test('ts-gate: done job uploaded after snapshot is dropped via either rule', () => {
|
||||
const jobs = [job('done', 'a.mkv', 'voe.sx')];
|
||||
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 1);
|
||||
});
|
||||
|
||||
test('ts-gate ambiguity guard: a pending same-basename file in a DIFFERENT folder is NOT lost when a sibling completes after the snapshot', () => {
|
||||
// X (C:/A/clip.mp4) was uploaded after the snapshot and logged. Y is a
|
||||
// genuinely-different file (C:/B/clip.mp4), same basename + hoster, still
|
||||
// pending. The log records only basenames, so the ts-rule must not drop Y.
|
||||
const jobs = [
|
||||
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
|
||||
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
|
||||
];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 0, 'ambiguous key -> ts-rule suppressed, no pending file lost');
|
||||
assert.equal(kept.length, 2);
|
||||
});
|
||||
|
||||
test('ts-gate ambiguity guard: the done-in-log rule still applies on an ambiguous key', () => {
|
||||
// Even when the key is ambiguous, a job that is actually 'done' and in the log
|
||||
// is still decluttered (pre-existing rule, unchanged by the guard).
|
||||
const jobs = [
|
||||
{ status: 'done', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
|
||||
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
|
||||
];
|
||||
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 1);
|
||||
assert.equal(removed[0].status, 'done');
|
||||
assert.equal(removed[0].file, 'C:/A/clip.mp4');
|
||||
assert.ok(kept.some(j => j.file === 'C:/B/clip.mp4'), 'the distinct pending file survives');
|
||||
});
|
||||
|
||||
test('ts-gate: a unique-path ghost still drops (guard does not weaken the common case)', () => {
|
||||
const jobs = [{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' }];
|
||||
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 1, 'single job for the key -> unambiguous -> ghost dropped as before');
|
||||
});
|
||||
|
||||
test('ts-gate: multi-hoster partial completion — the reported bug shape (drop only the completed hosters)', () => {
|
||||
// One file queued to 4 hosters; close mid-upload. After the snapshot, 2 hosters
|
||||
// completed (logged), 2 never started. On restart all 4 restore as 'preview'.
|
||||
// Must drop EXACTLY the 2 that completed and keep the 2 still-pending. This also
|
||||
// pins per-hoster keying: a fileName-only gate would wrongly drop all 4.
|
||||
const f = 'Einfach mal die Fresse halten!!!.mp4';
|
||||
const jobs = [
|
||||
job('preview', f, 'doodstream.com'),
|
||||
job('preview', f, 'voe.sx'),
|
||||
job('preview', f, 'vidmoly.me'),
|
||||
job('preview', f, 'byse.sx')
|
||||
];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const log = [
|
||||
{ fileName: f, hoster: 'doodstream.com', ts: T('2026-06-19 12:00:08') },
|
||||
{ fileName: f, hoster: 'voe.sx', ts: T('2026-06-19 12:00:11') }
|
||||
];
|
||||
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||
assert.equal(removed.length, 2, 'only the 2 completed-after-snapshot hosters drop');
|
||||
assert.ok(removed.every(j => j.hoster === 'doodstream.com' || j.hoster === 'voe.sx'));
|
||||
assert.equal(kept.length, 2, 'the 2 never-started hosters survive');
|
||||
assert.ok(kept.some(j => j.hoster === 'vidmoly.me'));
|
||||
assert.ok(kept.some(j => j.hoster === 'byse.sx'));
|
||||
});
|
||||
|
||||
test('completedSelectionKeys: a selectedFile that completed after the snapshot yields its full-path|hoster key', () => {
|
||||
const selectedFiles = [{ path: 'C:/dl/done.mp4', name: 'done.mp4' }, { path: 'C:/dl/pending.mp4', name: 'pending.mp4' }];
|
||||
const hosters = ['voe.sx'];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const log = [{ fileName: 'done.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
|
||||
assert.deepEqual(keys, ['C:/dl/done.mp4|voe.sx'], 'only the completed file is seeded; pending is not');
|
||||
});
|
||||
|
||||
test('completedSelectionKeys: per-hoster — a file done on voe but not byse only seeds the voe key', () => {
|
||||
const selectedFiles = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
|
||||
const hosters = ['voe.sx', 'byse.sx'];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
|
||||
assert.deepEqual(keys, ['C:/dl/a.mp4|voe.sx'], 'the still-pending byse upload is NOT seeded');
|
||||
});
|
||||
|
||||
test('completedSelectionKeys: ambiguous basename across folders seeds NOTHING (no lost re-preview)', () => {
|
||||
const selectedFiles = [{ path: 'C:/A/clip.mp4', name: 'clip.mp4' }, { path: 'C:/B/clip.mp4', name: 'clip.mp4' }];
|
||||
const hosters = ['voe.sx'];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
|
||||
assert.deepEqual(keys, [], 'ambiguous -> neither path is suppressed, both re-preview (safe direction)');
|
||||
});
|
||||
|
||||
test('completedSelectionKeys: an OLDER completion (pre-snapshot re-queue) is NOT seeded', () => {
|
||||
const selectedFiles = [{ path: 'C:/dl/reup.mp4', name: 'reup.mp4' }];
|
||||
const hosters = ['voe.sx'];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const log = [{ fileName: 'reup.mp4', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
|
||||
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), []);
|
||||
});
|
||||
|
||||
test('completedSelectionKeys: no savedAt / junk inputs -> empty (legacy + robustness)', () => {
|
||||
const sf = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
|
||||
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
assert.deepEqual(completedSelectionKeys(sf, ['voe.sx'], log, undefined), []);
|
||||
assert.deepEqual(completedSelectionKeys(null, ['voe.sx'], log, 1), []);
|
||||
assert.deepEqual(completedSelectionKeys(sf, null, log, 1), []);
|
||||
assert.deepEqual(completedSelectionKeys([], [], log, 1), []);
|
||||
assert.deepEqual(completedSelectionKeys([{ name: 'x' }], ['voe.sx'], log, 1), [], 'entry without path is skipped');
|
||||
});
|
||||
|
||||
test('completedSelectionKeys: derives basename from path when name is missing', () => {
|
||||
const selectedFiles = [{ path: 'C:/dl/sub/movie.mp4' }];
|
||||
const hosters = ['voe.sx'];
|
||||
const savedAt = T('2026-06-19 12:00:00');
|
||||
const log = [{ fileName: 'movie.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), ['C:/dl/sub/movie.mp4|voe.sx']);
|
||||
});
|
||||
Reference in New Issue
Block a user