diff --git a/CHANGELOG.md b/CHANGELOG.md index 591d5d48fb..4df932da22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this maintained LibreTV fork are documented here. +## 1.2.9 - 2026-05-12 + +### Changed + +- Source health checks now run up to 10 probes concurrently to reduce waiting time. +- Failed selected sources are automatically unchecked and saved after a health check. + ## 1.2.8 - 2026-05-11 ### Changed diff --git a/VERSION.txt b/VERSION.txt index a0dcf0fce1..63a581e5b4 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -202605112353 +202605120011 diff --git a/js/config.js b/js/config.js index a86539659f..eaefaf63b8 100644 --- a/js/config.js +++ b/js/config.js @@ -17,7 +17,7 @@ const SITE_CONFIG = { url: '', description: '免费在线视频搜索与观看平台', logo: 'image/logo.png', - version: '1.2.8' + version: '1.2.9' }; const DEFAULT_SELECTED_APIS = ['ysgc', 'jszy', 'wujin', 'maoyan']; diff --git a/js/source-health.js b/js/source-health.js index d8d1ea62c8..744ba6f0ed 100644 --- a/js/source-health.js +++ b/js/source-health.js @@ -2,6 +2,8 @@ const SOURCE_HEALTH_STORAGE_KEY = 'sourceHealthReport'; const SOURCE_HEALTH_CHECKED_AT_KEY = 'sourceHealthCheckedAt'; const SOURCE_HEALTH_TTL_MS = 24 * 60 * 60 * 1000; const SOURCE_HEALTH_PROBE_QUERY = '阿凡达'; +const SOURCE_HEALTH_CONCURRENCY = 10; +const SELECTED_APIS_STORAGE_KEY = 'selectedAPIs'; function getCustomHealthSourceIds() { try { @@ -182,9 +184,91 @@ function summarizeSourceHealth(sources) { return { total, ok, degraded, failed, successRate }; } +async function mapWithConcurrency(items, limit, mapper, onResult) { + const list = Array.from(items || []); + if (list.length === 0) return []; + + const workerCount = Math.min(Math.max(1, Number(limit) || 1), list.length); + const results = new Array(list.length); + let nextIndex = 0; + + async function runWorker() { + while (nextIndex < list.length) { + const index = nextIndex; + nextIndex += 1; + const result = await mapper(list[index], index); + results[index] = result; + onResult?.(result, index, results.filter(Boolean)); + } + } + + await Promise.all(Array.from({ length: workerCount }, runWorker)); + return results; +} + +function getSelectedApiIds() { + try { + const selected = JSON.parse(localStorage.getItem(SELECTED_APIS_STORAGE_KEY) || '[]'); + return Array.isArray(selected) ? selected : []; + } catch (error) { + console.warn('读取选中源失败:', error); + return []; + } +} + +function saveSelectedApiIds(sourceIds) { + localStorage.setItem(SELECTED_APIS_STORAGE_KEY, JSON.stringify(sourceIds)); + try { + if (typeof selectedAPIs !== 'undefined' && Array.isArray(selectedAPIs)) { + selectedAPIs = [...sourceIds]; + } + } catch { + // selectedAPIs is owned by app.js and may not exist on every page. + } +} + +function getSourceCheckbox(sourceId) { + if (sourceId.startsWith('custom_')) { + return document.getElementById(`custom_api_${sourceId.replace('custom_', '')}`); + } + return document.getElementById(`api_${sourceId}`); +} + +function refreshSelectedApiUi(removedSourceIds) { + removedSourceIds.forEach(sourceId => { + const checkbox = getSourceCheckbox(sourceId); + if (checkbox) checkbox.checked = false; + }); + + if (typeof updateSelectedApiCount === 'function') { + updateSelectedApiCount(); + } + if (typeof checkAdultAPIsSelected === 'function') { + checkAdultAPIsSelected(); + } +} + +function unselectFailedSources(sources) { + const failedSourceIds = sources + .filter(source => source?.status === 'failed') + .map(source => source.id); + if (failedSourceIds.length === 0) return []; + + const failedSet = new Set(failedSourceIds); + const selectedSourceIds = getSelectedApiIds(); + const nextSelectedSourceIds = selectedSourceIds.filter(sourceId => !failedSet.has(sourceId)); + if (nextSelectedSourceIds.length === selectedSourceIds.length) return []; + + const removedSourceIds = selectedSourceIds.filter(sourceId => failedSet.has(sourceId)); + saveSelectedApiIds(nextSelectedSourceIds); + refreshSelectedApiUi(removedSourceIds); + return removedSourceIds; +} + async function runSourceHealthCheck(sourceIds = getHealthSourceIds()) { const checkedAt = new Date().toISOString(); - const sources = []; + const sourceList = Array.from(sourceIds || []); + let sources = []; const button = document.getElementById('sourceHealthButton'); if (button) { button.disabled = true; @@ -192,9 +276,17 @@ async function runSourceHealthCheck(sourceIds = getHealthSourceIds()) { } try { - for (const sourceId of sourceIds) { - sources.push(await probeSourceHealth(sourceId)); - renderSourceHealthSummary({ version: window.SITE_CONFIG?.version || '', checkedAt, sources, summary: summarizeSourceHealth(sources) }); + sources = await mapWithConcurrency(sourceList, SOURCE_HEALTH_CONCURRENCY, probeSourceHealth, (_result, _index, completedSources) => { + renderSourceHealthSummary({ + version: window.SITE_CONFIG?.version || '', + checkedAt, + sources: completedSources, + summary: summarizeSourceHealth(completedSources) + }); + }); + const disabledSourceIds = unselectFailedSources(sources); + if (disabledSourceIds.length > 0 && typeof showToast === 'function') { + showToast(`已自动取消 ${disabledSourceIds.length} 个检测失败源`, 'warning'); } const report = { @@ -202,6 +294,7 @@ async function runSourceHealthCheck(sourceIds = getHealthSourceIds()) { checkedAt, ttlMs: SOURCE_HEALTH_TTL_MS, sources, + disabledSourceIds, summary: summarizeSourceHealth(sources) }; saveSourceHealthReport(report); @@ -272,6 +365,8 @@ window.SOURCE_HEALTH_STORAGE_KEY = SOURCE_HEALTH_STORAGE_KEY; window.loadSourceHealthReport = loadSourceHealthReport; window.saveSourceHealthReport = saveSourceHealthReport; window.getHealthSourceIds = getHealthSourceIds; +window.SOURCE_HEALTH_CONCURRENCY = SOURCE_HEALTH_CONCURRENCY; +window.unselectFailedSources = unselectFailedSources; window.probeSourceHealth = probeSourceHealth; window.runSourceHealthCheck = runSourceHealthCheck; window.renderSourceHealthSummary = renderSourceHealthSummary; diff --git a/package-lock.json b/package-lock.json index 9aeca3d9cd..9e585d61c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "libretv", - "version": "1.2.8", + "version": "1.2.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libretv", - "version": "1.2.8", + "version": "1.2.9", "license": "Apache-2.0", "dependencies": { "axios": "^1.9.0", diff --git a/package.json b/package.json index 7fcb6e4577..c3f26810ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "libretv", "type": "module", - "version": "1.2.8", + "version": "1.2.9", "private": true, "description": "免费在线视频搜索与观看平台", "author": "bestZwei", diff --git a/test/libretv-defaults.test.mjs b/test/libretv-defaults.test.mjs index 308cd1e6be..d124d9404c 100644 --- a/test/libretv-defaults.test.mjs +++ b/test/libretv-defaults.test.mjs @@ -383,6 +383,123 @@ test('source health selection includes all built-in and custom sources', async ( assert.ok(sourceIds.some(sourceId => sandbox.window.API_SITES[sourceId]?.adult)); }); +test('source health checks run with a ten source concurrency limit', async () => { + let activeSearches = 0; + let maxActiveSearches = 0; + const sandbox = { + console, + URL, + window: { + API_SITES: {}, + SITE_CONFIG: { version: 'test' }, + PROXY_URL: '/proxy/', + location: { origin: 'https://libretv.example' } + }, + localStorage: { + getItem() { + return null; + }, + setItem() {} + }, + document: { + addEventListener() {}, + getElementById() { + return null; + } + }, + performance: { + now() { + return 0; + } + }, + searchByAPIAndKeyWord: async sourceId => { + activeSearches += 1; + maxActiveSearches = Math.max(maxActiveSearches, activeSearches); + await new Promise(resolve => setTimeout(resolve, 10)); + activeSearches -= 1; + return [{ vod_id: `${sourceId}-1`, vod_name: sourceId }]; + }, + handleApiRequest: async () => JSON.stringify({ + code: 200, + episodes: ['https://media.example/video.m3u8'] + }), + fetch: async () => ({ ok: true }), + setTimeout, + clearTimeout, + AbortController + }; + vm.createContext(sandbox); + vm.runInContext(await readProjectFile('js/source-health.js'), sandbox); + + const sourceIds = Array.from({ length: 12 }, (_, index) => `source_${index}`); + const report = await sandbox.window.runSourceHealthCheck(sourceIds); + + assert.equal(report.sources.length, 12); + assert.equal(maxActiveSearches, 10); +}); + +test('source health checks unselect failed sources for the current user', async () => { + const storage = new Map([ + ['selectedAPIs', JSON.stringify(['ok_source', 'failed_source', 'custom_0'])] + ]); + const checkboxState = new Map([ + ['api_ok_source', { checked: true }], + ['api_failed_source', { checked: true }], + ['custom_api_0', { checked: true }] + ]); + const sandbox = { + console, + URL, + window: { + API_SITES: {}, + SITE_CONFIG: { version: 'test' }, + PROXY_URL: '/proxy/', + location: { origin: 'https://libretv.example' } + }, + localStorage: { + getItem(key) { + return storage.get(key) || null; + }, + setItem(key, value) { + storage.set(key, value); + } + }, + document: { + addEventListener() {}, + getElementById(id) { + return checkboxState.get(id) || null; + } + }, + performance: { + now() { + return 0; + } + }, + searchByAPIAndKeyWord: async sourceId => { + if (sourceId === 'failed_source') return []; + return [{ vod_id: `${sourceId}-1`, vod_name: sourceId }]; + }, + handleApiRequest: async () => JSON.stringify({ + code: 200, + episodes: ['https://media.example/video.m3u8'] + }), + fetch: async () => ({ ok: true }), + updateSelectedApiCount() {}, + checkAdultAPIsSelected() {}, + setTimeout, + clearTimeout, + AbortController + }; + vm.createContext(sandbox); + vm.runInContext(await readProjectFile('js/source-health.js'), sandbox); + + const report = await sandbox.window.runSourceHealthCheck(['ok_source', 'failed_source', 'custom_0']); + + assert.deepEqual(JSON.parse(storage.get('selectedAPIs')), ['ok_source', 'custom_0']); + assert.equal(checkboxState.get('api_failed_source').checked, false); + assert.equal(report.disabledSourceIds.includes('failed_source'), true); +}); + test('playback errors are classified and offer one-click source switching', async () => { const playerErrors = await readProjectFile('js/player-errors.js'); const player = await readProjectFile('js/player.js'); @@ -479,11 +596,11 @@ test('release metadata is bumped for this update', async () => { const changelog = await readProjectFile('CHANGELOG.md'); - assert.equal(packageJson.version, '1.2.8'); - assert.equal(lockJson.version, '1.2.8'); - assert.equal(lockJson.packages[''].version, '1.2.8'); - assert.match(config, /version:\s*'1\.2\.8'/); - assert.match(changelog, /1\.2\.8/); + assert.equal(packageJson.version, '1.2.9'); + assert.equal(lockJson.version, '1.2.9'); + assert.equal(lockJson.packages[''].version, '1.2.9'); + assert.match(config, /version:\s*'1\.2\.9'/); + assert.match(changelog, /1\.2\.9/); assert.match(changelog, /检测源|Source health checks/); assert.match(versionTxt, /^\d{12}$/); assert.ok(Number(versionTxt) > 202508060117);