WeebCentral and MangaPill are off by default, with an admin switch per source

Both are unofficial sites that mostly host unlicensed copies; MangaDex (official
API) stays on. Settings → Admin → Scraper → Hoster settings has a 'Use <source>'
switch. A switched-off source refuses new jobs, keeps queued jobs waiting and is
skipped by nightly update checks. Installations whose queue or history already
used a source keep it switched on, so upgrades don't stop a working queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-25 07:47:47 +01:00
1 parent 336d3325fe
commit 57a571d2f0
6 files changed
+84 -10

No files matched your search

+35 -4
View File
@@ -24,9 +24,13 @@ const DEFAULT_SOURCE_RATES = {
mangadex: { ...FAST_RATE_SETTINGS },
mangapill: { ...FAST_RATE_SETTINGS },
};
// Sources an admin must switch on first. WeebCentral and MangaPill mostly host unlicensed
// copies; MangaDex has an official API and handles takedowns. Peers are always available.
const DEFAULT_SOURCES_ENABLED = { weebcentral: false, mangadex: true, mangapill: false };
const DEFAULT_SETTINGS = {
...FAST_RATE_SETTINGS,
sourceRates: Object.fromEntries(Object.entries(DEFAULT_SOURCE_RATES).map(([source, rate]) => [source, { ...rate }])),
sourcesEnabled: { ...DEFAULT_SOURCES_ENABLED },
autoUpdateEnabled: true, autoUpdateTime: '03:00',
};
@@ -73,6 +77,15 @@ function validateSettings(input = {}, prior = DEFAULT_SETTINGS) {
};
next.sourceRates[source] = validateRateSettings(patch || {}, base, `sourceRates.${source}.`);
}
if (input.sourcesEnabled !== undefined && (!input.sourcesEnabled || typeof input.sourcesEnabled !== 'object' || Array.isArray(input.sourcesEnabled))) {
throw new Error('sourcesEnabled must be an object');
}
next.sourcesEnabled = { ...DEFAULT_SOURCES_ENABLED, ...(prior.sourcesEnabled || {}) };
for (const [source, enabled] of Object.entries(input.sourcesEnabled || {})) {
if (!Object.hasOwn(DEFAULT_SOURCES_ENABLED, source)) throw new Error(`Unknown source: ${source}`);
if (typeof enabled !== 'boolean') throw new Error(`sourcesEnabled.${source} must be true or false`);
next.sourcesEnabled[source] = enabled;
}
if (input.autoUpdateEnabled !== undefined) next.autoUpdateEnabled = !!input.autoUpdateEnabled;
if (input.autoUpdateTime !== undefined) {
const value = String(input.autoUpdateTime).trim();
@@ -86,6 +99,13 @@ function settingsForSource(settings, source) {
return { ...settings, ...((settings.sourceRates || {})[source] || {}) };
}
// Installations from before the switch keep every source they already use: off-by-default
// applies to new installations, it must not silently stop a working queue.
function inheritedSourcesEnabled(saved) {
const used = new Set([...(saved.queue || []), ...(saved.history || [])].map(item => item && item.source));
return Object.fromEntries(Object.entries(DEFAULT_SOURCES_ENABLED).map(([source, enabled]) => [source, enabled || used.has(source)]));
}
function existingPolicy(value) {
const policy = String(value || 'skip-any');
if (!['skip-any', 'add-source'].includes(policy)) throw new Error('existingPolicy must be skip-any or add-source');
@@ -217,11 +237,13 @@ class ScraperService {
async loadState() {
if (await exists(this.stateFile)) {
const saved = JSON.parse(await fs.readFile(this.stateFile, 'utf8'));
const savedSettings = saved.settings || {};
if (!savedSettings.sourcesEnabled) savedSettings.sourcesEnabled = inheritedSourcesEnabled(saved);
return {
version: 3, paused: false, status: 'idle', queue: [], history: [], publishedRevision: 0,
currentIds: {}, sourcePaused: {},
settings: DEFAULT_SETTINGS, ...saved,
settings: validateSettings(saved.settings || {}, DEFAULT_SETTINGS),
settings: validateSettings(savedSettings, DEFAULT_SETTINGS),
};
}
const state = {
@@ -239,7 +261,7 @@ class ScraperService {
status: completed.has(item.id) ? 'complete' : 'queued', order: index,
added: legacy.created || new Date().toISOString(),
}));
state.settings = validateSettings(legacy.settings || {}, DEFAULT_SETTINGS);
state.settings = validateSettings({ ...(legacy.settings || {}), sourcesEnabled: inheritedSourcesEnabled(state) }, DEFAULT_SETTINGS);
state.migratedFrom = this.legacyStateFile;
}
return state;
@@ -401,7 +423,8 @@ class ScraperService {
let queued = 0, skipped = 0;
for (const manga of inventory.mangas) {
if (!manga.autoUpdate) continue;
const supported = manga.sources.map(source => ({ source, found: this.resolveAdapter(source.url) })).filter(item => item.found);
const supported = manga.sources.map(source => ({ source, found: this.resolveAdapter(source.url) }))
.filter(item => item.found && this.sourceEnabled(item.found.adapter.id));
if (!supported.length) { skipped++; continue; }
const preferred = manga.meta?.['x-scraper']?.preferredSource;
const selected = supported.find(item => item.found.adapter.id === preferred) || supported[0];
@@ -598,9 +621,16 @@ class ScraperService {
};
}
sourceEnabled(source) {
return !Object.hasOwn(DEFAULT_SOURCES_ENABLED, source) || this.state.settings.sourcesEnabled?.[source] === true;
}
async enqueue(input) {
const found = this.resolveAdapter(input.url);
if (!found) throw new Error('No installed scraper adapter accepts this URL');
if (!this.sourceEnabled(found.adapter.id)) {
throw new Error(`${found.adapter.label || found.adapter.id} is switched off. An admin can switch it on under Settings → Admin → Scraper → Hoster settings.`);
}
const workIdentity = normalizeWorkIdentity(input.workIdentity);
if (input.workIdentity != null && !workIdentity) throw new Error('Invalid manga work identity');
const scope = normalizeScope(input.scope || {});
@@ -902,7 +932,8 @@ class ScraperService {
continue;
}
const source = found.adapter.id;
if (this.active.has(source) || this.state.sourcePaused[source]) continue;
// A switched-off source keeps its queued jobs; they wait until it is switched on again.
if (this.active.has(source) || this.state.sourcePaused[source] || !this.sourceEnabled(source)) continue;
if (this.state.circuits[source]?.state === 'open') continue;
if (item.libraryMangaId && [...this.active.values()].some(entry => {
const activeItem = this.state.queue.find(candidate => candidate.id === entry.id);
+27
View File
@@ -426,6 +426,9 @@ test('daemon queue state is durable while paused', async t => {
}));
const service = await new ScraperService({ libraryRoot: path.join(temp, 'library'), dataRoot }).init();
t.after(async () => { await service.close(); await fs.rm(temp, { recursive: true, force: true }); });
// WeebCentral is off on a fresh installation until an admin switches it on.
await assert.rejects(() => service.enqueue({ url: 'https://weebcentral.com/series/01ABCDEF/My-Series' }), /switched off/);
await service.updateSettings({ sourcesEnabled: { weebcentral: true } });
const item = await service.enqueue({ url: 'https://weebcentral.com/series/01ABCDEF/My-Series' });
assert.equal(item.status, 'queued');
const scoped = await service.enqueue({ url: 'https://weebcentral.com/series/01ABCDEF/My-Series', scope: { mode: 'latest' } });
@@ -439,6 +442,30 @@ test('daemon queue state is durable while paused', async t => {
assert.equal((await service.publicState()).queue.length, 0);
});
test('sources are off by default but existing installations keep the ones they use', async t => {
assert.deepEqual(validateSettings({}).sourcesEnabled, { weebcentral: false, mangadex: true, mangapill: false });
assert.throws(() => validateSettings({ sourcesEnabled: { weebcentral: 'yes' } }), /true or false/);
assert.throws(() => validateSettings({ sourcesEnabled: { other: true } }), /Unknown source/);
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcscraper-sources-'));
const dataRoot = path.join(temp, 'data');
await fs.mkdir(dataRoot, { recursive: true });
const { sourcesEnabled, ...older } = validateSettings({});
await fs.writeFile(path.join(dataRoot, 'queue.json'), JSON.stringify({
version: 3, paused: true, status: 'paused', settings: older, publishedRevision: 0,
queue: [{ id: 'A', title: 'A', url: 'https://weebcentral.com/series/AAA/A', source: 'weebcentral', status: 'queued', order: 0 }],
history: [],
}));
const service = await new ScraperService({ libraryRoot: path.join(temp, 'library'), dataRoot }).init();
t.after(async () => { await service.close(); await fs.rm(temp, { recursive: true, force: true }); });
assert.deepEqual(service.state.settings.sourcesEnabled, { weebcentral: true, mangadex: true, mangapill: false });
await assert.rejects(() => service.enqueue({ url: 'https://mangapill.com/manga/2/one-piece' }), /MangaPill is switched off/);
// Switching a source off keeps its jobs queued instead of failing them.
await service.updateSettings({ sourcesEnabled: { weebcentral: false } });
assert.equal(service.sourceEnabled('weebcentral'), false);
assert.equal(service.sourceEnabled('peer-abc'), true, 'peers have no switch');
assert.equal(service.state.queue[0].status, 'queued');
});
test('retry recovers stale downloading entries before work resumes', async t => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcscraper-retry-'));
const dataRoot = path.join(temp, 'data');