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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
202605112353
202605120011
2 changes: 1 addition & 1 deletion js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
103 changes: 99 additions & 4 deletions js/source-health.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -182,26 +184,117 @@ 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;
button.textContent = '检测中...';
}

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 = {
version: window.SITE_CONFIG?.version || '',
checkedAt,
ttlMs: SOURCE_HEALTH_TTL_MS,
sources,
disabledSourceIds,
summary: summarizeSourceHealth(sources)
};
saveSourceHealthReport(report);
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "libretv",
"type": "module",
"version": "1.2.8",
"version": "1.2.9",
"private": true,
"description": "免费在线视频搜索与观看平台",
"author": "bestZwei",
Expand Down
127 changes: 122 additions & 5 deletions test/libretv-defaults.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
Loading